drupal-mcp-connector 2.4.1 → 2.6.0

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.
@@ -0,0 +1,916 @@
1
+ /**
2
+ * Secure-install verification (#180).
3
+ *
4
+ * Answers one question with evidence rather than assertion: does THIS
5
+ * connector installation carry the secure, tenant-neutral defaults the
6
+ * governed product claims? The static half needs no network and no
7
+ * credentials — it reads the configuration a clean install ships with, so it
8
+ * runs in CI and in a release proof. The live half (see verifyLive) proves the
9
+ * same claims against a running target.
10
+ *
11
+ * Two rules shape every check:
12
+ *
13
+ * - A check that cannot run is `skipped`, never `pass`. Silence is not
14
+ * evidence, and a verifier that reports success for something it never
15
+ * exercised is worse than no verifier.
16
+ * - Nothing secret reaches the result. The evidence carries names, hosts and
17
+ * outcomes — never a token, a secret, or the value of an env var.
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+ import { CLIENT_VERSION } from "./config.js";
22
+
23
+ /** Check outcome vocabulary.
24
+ *
25
+ * `skipped` and `n/a` are deliberately different. A check that SHOULD have run
26
+ * and could not is `skipped`, and a skipped check fails the run — silence is
27
+ * not evidence. A check that does not apply to this shape of install (no OAuth
28
+ * to scope, no second role to separate, no config surface to deny) is `n/a`
29
+ * and does not fail it: a verifier a secure install can never pass is a
30
+ * verifier people stop running.
31
+ */
32
+ export const PASS = "pass";
33
+ export const FAIL = "fail";
34
+ export const SKIPPED = "skipped";
35
+ export const NOT_APPLICABLE = "n/a";
36
+
37
+ /** Every static check, in report order. */
38
+ export const STATIC_CHECKS = [
39
+ "transport",
40
+ "principal_auth",
41
+ "scope_grant",
42
+ "source_governance",
43
+ "role_separation",
44
+ "entitlement",
45
+ "target_resolution",
46
+ "tenant_neutrality",
47
+ ];
48
+
49
+ /**
50
+ * Named residuals: properties this stack manages rather than solves.
51
+ *
52
+ * They are part of the evidence on purpose. A release proof that lists only
53
+ * what passed reads as a claim that nothing else is outstanding.
54
+ */
55
+ export const RESIDUALS = [
56
+ {
57
+ id: "prompt_injection",
58
+ status: "managed",
59
+ detail:
60
+ "Prompt injection is not solved by this connector. Instruction-shaped content " +
61
+ "reaching an agent through governed reads can still attempt to redirect it. " +
62
+ "What the stack constrains is the blast radius: least-privilege scopes, per-role " +
63
+ "presets, source-side governance (entity/field denies, egress ceilings, finite " +
64
+ "read budgets), no agent publication authority, and an audit trail of every " +
65
+ "governed action. Treat model output as untrusted input to any subsequent step.",
66
+ },
67
+ {
68
+ id: "operator_trust",
69
+ status: "managed",
70
+ detail:
71
+ "An operator who holds the client secrets can act with the agent's authority. " +
72
+ "Secret custody, rotation and revocation stay the deploying organisation's " +
73
+ "responsibility; the connector reads secrets from the environment and never " +
74
+ "stores them.",
75
+ },
76
+ ];
77
+
78
+ /** Hostname suffixes reserved for documentation and testing (RFC 2606/6761). */
79
+ const RESERVED_SUFFIXES = [".example.com", ".example.org", ".example.net", ".example", ".test", ".invalid", ".localhost"];
80
+
81
+ /** Exact hostnames that are reserved, or public project infrastructure. */
82
+ const RESERVED_HOSTS = new Set([
83
+ "example.com",
84
+ "example.org",
85
+ "example.net",
86
+ "localhost",
87
+ "127.0.0.1",
88
+ "::1",
89
+ // Public project hosts an example may legitimately name: they identify the
90
+ // Drupal project itself, not the tenant deploying it.
91
+ "www.drupal.org",
92
+ "drupal.org",
93
+ ]);
94
+
95
+ /**
96
+ * Whether a hostname is safe to ship in an example: reserved for documentation,
97
+ * loopback, or public project infrastructure.
98
+ * @param {string} host Hostname, without scheme or port.
99
+ * @returns {boolean}
100
+ */
101
+ export function isNeutralHost(host) {
102
+ const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, "");
103
+ if (!h) return false;
104
+ if (RESERVED_HOSTS.has(h)) return true;
105
+ if (/^127\.\d+\.\d+\.\d+$/.test(h)) return true;
106
+ return RESERVED_SUFFIXES.some((suffix) => h.endsWith(suffix));
107
+ }
108
+
109
+ /** Whether a URL points at the loopback interface. */
110
+ function isLoopback(url) {
111
+ const host = hostOf(url);
112
+ return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
113
+ }
114
+
115
+ /** The hostname of a URL string, or "" when it cannot be parsed. */
116
+ function hostOf(url) {
117
+ try {
118
+ return new URL(String(url)).hostname.toLowerCase();
119
+ } catch {
120
+ return "";
121
+ }
122
+ }
123
+
124
+ /** Whether a site declares OAuth or the source-governance requirement. */
125
+ function isGoverned(site) {
126
+ return site?.requireGovernance === true || Boolean(site?.oauth);
127
+ }
128
+
129
+ /** Whether a site is an explicitly local development target. */
130
+ function isLocalDevelopment(site) {
131
+ return isLoopback(site?.baseUrl) && site?.security?.preset === "development";
132
+ }
133
+
134
+ /** Builds one check result. */
135
+ function check(id, title, findings, { skipped = false, notApplicable = false, reason = "" } = {}) {
136
+ const status = notApplicable ? NOT_APPLICABLE : skipped ? SKIPPED : findings.length === 0 ? PASS : FAIL;
137
+ return { id, title, status, findings: (skipped || notApplicable) && reason ? [reason] : findings };
138
+ }
139
+
140
+ /**
141
+ * Every string value in a nested structure, with its path.
142
+ * @param {*} value Any JSON-ish value.
143
+ * @param {string[]} path Accumulated key path.
144
+ * @returns {Array<{path: string, value: string}>}
145
+ */
146
+ function strings(value, path = []) {
147
+ if (typeof value === "string") return [{ path: path.join("."), value }];
148
+ if (Array.isArray(value)) return value.flatMap((v, i) => strings(v, [...path, String(i)]));
149
+ if (value && typeof value === "object") {
150
+ return Object.entries(value).flatMap(([k, v]) => strings(v, [...path, k]));
151
+ }
152
+ return [];
153
+ }
154
+
155
+ /**
156
+ * Whether a bare string looks like a hostname.
157
+ *
158
+ * Deliberately label-by-label rather than one nested-quantifier regex: the
159
+ * pattern form of this test backtracks catastrophically on a long dotless
160
+ * string, and a verifier is not a place to introduce a denial of service.
161
+ * @param {string} str Candidate.
162
+ * @returns {boolean}
163
+ */
164
+ function looksLikeHostname(str) {
165
+ if (typeof str !== "string" || str.length < 4 || str.length > 253) return false;
166
+ const labels = str.split(".");
167
+ if (labels.length < 2) return false;
168
+ if (!/^[a-z]{2,}$/i.test(labels[labels.length - 1])) return false;
169
+ return labels.slice(0, -1).every((label) => /^[a-z0-9-]+$/i.test(label));
170
+ }
171
+
172
+ /**
173
+ * Hostnames mentioned anywhere in a value: URLs, and bare host-shaped strings.
174
+ * @param {*} value Any JSON-ish value.
175
+ * @returns {Array<{path: string, host: string}>}
176
+ */
177
+ function mentionedHosts(value) {
178
+ const found = [];
179
+ for (const { path, value: str } of strings(value)) {
180
+ // Documentation prose (the "_comment" keys) is not configuration; it is
181
+ // reviewed, not parsed.
182
+ if (path.split(".").some((segment) => segment.startsWith("_"))) continue;
183
+ const urlHost = hostOf(str);
184
+ if (urlHost) {
185
+ found.push({ path, host: urlHost });
186
+ continue;
187
+ }
188
+ if (looksLikeHostname(str)) {
189
+ found.push({ path, host: str.toLowerCase() });
190
+ }
191
+ }
192
+ return found;
193
+ }
194
+
195
+ /**
196
+ * A stable digest of the verified configuration, secrets excluded.
197
+ *
198
+ * Ties an evidence document to the exact input that produced it without
199
+ * carrying that input — or any secret in it — into the result.
200
+ * @param {object} config The configuration under verification.
201
+ * @returns {string} `sha256:<hex>`
202
+ */
203
+ export function configDigest(config) {
204
+ const redactKeys = new Set(["clientSecret", "apiToken", "secret", "password"]);
205
+ const normalize = (value) => {
206
+ if (Array.isArray(value)) return value.map(normalize);
207
+ if (value && typeof value === "object") {
208
+ return Object.keys(value)
209
+ .sort()
210
+ .reduce((out, key) => {
211
+ out[key] = redactKeys.has(key) ? "[REDACTED]" : normalize(value[key]);
212
+ return out;
213
+ }, {});
214
+ }
215
+ return value;
216
+ };
217
+ return "sha256:" + createHash("sha256").update(JSON.stringify(normalize(config))).digest("hex");
218
+ }
219
+
220
+ /**
221
+ * Verify a configuration's secure, tenant-neutral defaults. No network, no
222
+ * credentials, no side effects.
223
+ *
224
+ * @param {object} config Parsed connector configuration.
225
+ * @param {{source?: string, now?: () => Date}} [options]
226
+ * `source` names what was verified (a path, or a label) for the evidence;
227
+ * `now` is injectable so a run is reproducible in tests.
228
+ * @returns {object} Evidence document: tool, version, subject, checks,
229
+ * residuals and a summary. Never contains secret values.
230
+ */
231
+ export function verifyStatic(config, { source = "config", now = () => new Date() } = {}) {
232
+ const sites = Object.entries(config?.sites ?? {});
233
+ const named = (name, message) => `${name}: ${message}`;
234
+ const nothingToCheck = sites.length === 0;
235
+
236
+ const transport = check(
237
+ "transport",
238
+ "Every site is reached over HTTPS (or an explicit loopback target)",
239
+ sites.flatMap(([name, site]) => {
240
+ const url = String(site?.baseUrl ?? "");
241
+ if (url.startsWith("https://")) return [];
242
+ if (isLoopback(url)) return [];
243
+ return [named(name, `baseUrl is not HTTPS (${url || "missing"}).`)];
244
+ }),
245
+ { skipped: nothingToCheck },
246
+ );
247
+
248
+ const principalAuth = check(
249
+ "principal_auth",
250
+ "Every site authenticates as a named principal, with the secret out of the file",
251
+ sites.flatMap(([name, site]) => {
252
+ const findings = [];
253
+ const oauth = site?.oauth;
254
+ if (oauth) {
255
+ if (!oauth.clientId) findings.push(named(name, "OAuth block has no clientId."));
256
+ if (oauth.clientSecret) {
257
+ findings.push(named(name, "OAuth clientSecret is inline; use clientSecretEnv so the secret stays out of the config file."));
258
+ } else if (!oauth.clientSecretEnv) {
259
+ findings.push(named(name, "OAuth block names no clientSecretEnv."));
260
+ }
261
+ } else if (site?.apiToken) {
262
+ findings.push(named(name, "apiToken is inline; use apiTokenEnv so the secret stays out of the config file."));
263
+ } else if (!site?.apiTokenEnv) {
264
+ findings.push(named(name, "no credential configured (neither an oauth block nor apiTokenEnv)."));
265
+ }
266
+ if (isGoverned(site) && site?.requireSecureAuth !== true) {
267
+ findings.push(named(name, "requireSecureAuth is not set on a governed site; anonymous and basic auth would be accepted."));
268
+ }
269
+ return findings;
270
+ }),
271
+ { skipped: nothingToCheck },
272
+ );
273
+
274
+ const oauthSites = sites.filter(([, site]) => Boolean(site?.oauth));
275
+ const scopeGrant = check(
276
+ "scope_grant",
277
+ "Every OAuth site names the scopes its token carries (no empty-scope bypass)",
278
+ oauthSites.flatMap(([name, site]) => {
279
+ const scopes = site.oauth.scopes ?? [];
280
+ return Array.isArray(scopes) && scopes.length > 0
281
+ ? []
282
+ : [named(name, "OAuth block names no scopes; an unnamed grant is not a wildcard and every scope gate will now deny.")];
283
+ }),
284
+ {
285
+ notApplicable: oauthSites.length === 0,
286
+ reason: "no site authenticates with OAuth, so there are no scopes to name.",
287
+ },
288
+ );
289
+
290
+ const sourceGovernance = check(
291
+ "source_governance",
292
+ "Governed sites require the source-governance contract",
293
+ sites.flatMap(([name, site]) => {
294
+ if (!isGoverned(site) || isLocalDevelopment(site)) return [];
295
+ return site.requireGovernance === true
296
+ ? []
297
+ : [named(name, "requireGovernance is not set; the connector would fall back to an ungoverned JSON:API or GraphQL path.")];
298
+ }),
299
+ { skipped: nothingToCheck },
300
+ );
301
+
302
+ const roleSeparation = check(
303
+ "role_separation",
304
+ "Each role carries its own client id and its own secret",
305
+ (() => {
306
+ const findings = [];
307
+ const byEnv = new Map();
308
+ const byClient = new Map();
309
+ for (const [name, site] of oauthSites) {
310
+ const env = site.oauth.clientSecretEnv;
311
+ const client = site.oauth.clientId;
312
+ if (env) byEnv.set(env, [...(byEnv.get(env) ?? []), name]);
313
+ if (client) byClient.set(client, [...(byClient.get(client) ?? []), name]);
314
+ }
315
+ for (const [env, names] of byEnv) {
316
+ if (names.length > 1) {
317
+ findings.push(`${names.join(", ")}: share the secret env var ${env}; a compromise of one role is a compromise of all of them.`);
318
+ }
319
+ }
320
+ for (const [client, names] of byClient) {
321
+ if (names.length > 1) {
322
+ findings.push(`${names.join(", ")}: share the OAuth client id "${client}"; separate roles need separate principals.`);
323
+ }
324
+ }
325
+ return findings;
326
+ })(),
327
+ {
328
+ notApplicable: oauthSites.length === 0,
329
+ reason: "no site authenticates with OAuth, so there are no principals to separate.",
330
+ },
331
+ );
332
+
333
+ const entitlement = check(
334
+ "entitlement",
335
+ "Every site pins a security preset, and the permissive preset stays local",
336
+ sites.flatMap(([name, site]) => {
337
+ const preset = site?.security?.preset;
338
+ if (!preset) return [named(name, "no security preset configured; the connector's entitlement layer is unpinned.")];
339
+ if (preset === "development" && !isLoopback(site?.baseUrl)) {
340
+ return [named(name, "the \"development\" preset allows every operation and is for loopback targets only.")];
341
+ }
342
+ return [];
343
+ }),
344
+ { skipped: nothingToCheck },
345
+ );
346
+
347
+ const targetResolution = check(
348
+ "target_resolution",
349
+ "Every site resolves to exactly one target, and the default site exists",
350
+ (() => {
351
+ const findings = [];
352
+ if (nothingToCheck) findings.push("no sites are configured; there is nothing to resolve a tool call to.");
353
+ for (const [name, site] of sites) {
354
+ if (!site?.baseUrl) findings.push(named(name, "no baseUrl; the site cannot be resolved to a target."));
355
+ }
356
+ const def = config?.defaultSite;
357
+ if (def && !Object.prototype.hasOwnProperty.call(config?.sites ?? {}, def)) {
358
+ findings.push(`defaultSite "${def}" names a site that does not exist.`);
359
+ }
360
+ return findings;
361
+ })(),
362
+ );
363
+
364
+ const tenantNeutrality = check(
365
+ "tenant_neutrality",
366
+ "The configuration names no real tenant hosts or identifiers",
367
+ mentionedHosts(config?.sites ?? {})
368
+ .filter(({ host }) => !isNeutralHost(host))
369
+ .map(({ path, host }) => `${path}: "${host}" is not a documentation-reserved host; a shipped example must not name a real deployment.`),
370
+ { skipped: nothingToCheck },
371
+ );
372
+
373
+ const checks = [
374
+ transport,
375
+ principalAuth,
376
+ scopeGrant,
377
+ sourceGovernance,
378
+ roleSeparation,
379
+ entitlement,
380
+ targetResolution,
381
+ tenantNeutrality,
382
+ ];
383
+
384
+ const counts = checks.reduce(
385
+ (acc, c) => ({ ...acc, [c.status]: acc[c.status] + 1 }),
386
+ { [PASS]: 0, [FAIL]: 0, [SKIPPED]: 0, [NOT_APPLICABLE]: 0 },
387
+ );
388
+
389
+ return {
390
+ tool: "drupal-mcp-connector verify",
391
+ mode: "static",
392
+ connectorVersion: CLIENT_VERSION,
393
+ generatedAt: now().toISOString(),
394
+ subject: {
395
+ source,
396
+ siteCount: sites.length,
397
+ sites: sites.map(([name]) => name),
398
+ configDigest: configDigest(config ?? {}),
399
+ },
400
+ checks,
401
+ residuals: RESIDUALS,
402
+ summary: {
403
+ pass: counts[PASS],
404
+ fail: counts[FAIL],
405
+ skipped: counts[SKIPPED],
406
+ notApplicable: counts[NOT_APPLICABLE],
407
+ // A skipped check is not a pass: an install is only verified when every
408
+ // applicable check actually ran and none failed. A not-applicable check
409
+ // does not block — it never had anything to prove.
410
+ ok: counts[FAIL] === 0 && counts[SKIPPED] === 0,
411
+ },
412
+ };
413
+ }
414
+
415
+ /** Every live check, in report order. */
416
+ export const LIVE_CHECKS = [
417
+ "transport",
418
+ "principal_auth",
419
+ "source_governance",
420
+ "entitlement_filtering",
421
+ "target_resolution",
422
+ "probe_mass_read",
423
+ "probe_config_change",
424
+ "probe_content_edit",
425
+ ];
426
+
427
+ /** A page size no governed profile should ever serve in one response. */
428
+ const MASS_READ_LIMIT = 5000;
429
+
430
+ /** The governed config-write tool, under the bridge's derivative name. */
431
+ const CONFIG_SET_TOOL = "tool_api.mcp_sentinel_config_set";
432
+
433
+ /** Joins a base URL and a path without doubling or dropping the separator. */
434
+ function joinUrl(baseUrl, path) {
435
+ return `${String(baseUrl).replace(/\/+$/, "")}/${String(path).replace(/^\/+/, "")}`;
436
+ }
437
+
438
+ /**
439
+ * One transport attempt, reduced to what the evidence may carry.
440
+ *
441
+ * Never returns a body: a governed read's payload is the very thing that must
442
+ * not end up in a verification artefact. What is recorded is the status, and
443
+ * the stable error/reason codes the source uses to explain a refusal.
444
+ *
445
+ * @param {Function} transport fetch-shaped transport.
446
+ * @param {string} url Absolute URL.
447
+ * @param {object} [init] fetch init.
448
+ * @returns {Promise<{status: number|null, ok: boolean, codes: string[], reason: string|null, count: number|null, error: string|null}>}
449
+ */
450
+ async function attempt(transport, url, init = {}) {
451
+ try {
452
+ const response = await transport(url, init);
453
+ let body = null;
454
+ try {
455
+ body = await response.json();
456
+ } catch {
457
+ body = null;
458
+ }
459
+ const codes = Array.isArray(body?.errors)
460
+ ? body.errors.map((e) => e?.code).filter(Boolean)
461
+ : [];
462
+ return {
463
+ status: response.status ?? null,
464
+ ok: Boolean(response.ok),
465
+ codes,
466
+ reason: typeof body?.reason === "string" ? body.reason : null,
467
+ count: Array.isArray(body?.data) ? body.data.length : null,
468
+ error: typeof body?.error === "string" ? body.error : null,
469
+ };
470
+ } catch (err) {
471
+ return { status: null, ok: false, codes: [], reason: null, count: null, error: String(err?.message ?? err) };
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Whether a thrown bridge error is a POLICY refusal or an unexercised probe.
477
+ *
478
+ * A probe that passes "because the target refused" must prove which refusal it
479
+ * read. `callServerTool` throws for several unrelated reasons, and only some of
480
+ * them mean the source decided:
481
+ *
482
+ * - a tool-level error (the tool ran and refused) → refused
483
+ * - a server-defined JSON-RPC error (-32000..-32099) → refused
484
+ * - an HTTP 401/403 on the tools/call → refused
485
+ * - a standard JSON-RPC error (method not found, bad params) → unexercised
486
+ * - any other HTTP status (400, 404, 5xx) → unexercised
487
+ * - no bridge configured, session init, network failure → unexercised
488
+ *
489
+ * Scoring an unexercised probe as a refusal is how a verifier produces a green
490
+ * document for an install that never proved anything.
491
+ *
492
+ * @param {Error} error Thrown by the bridge client.
493
+ * @returns {{outcome: "refused"|"unexercised", detail: string}}
494
+ */
495
+ export function classifyBridgeError(error) {
496
+ const message = String(error?.message ?? error);
497
+ const detail = message.slice(0, 200);
498
+
499
+ // The tool ran and reported an error: the canonical governed refusal.
500
+ if (/ reported an error:/.test(message)) return { outcome: "refused", detail };
501
+
502
+ // JSON-RPC error at the tools/call. Server-defined codes are decisions;
503
+ // the standard codes mean the call itself was wrong.
504
+ const rpc = message.match(/ error \((-?\d+)\):/);
505
+ if (rpc) {
506
+ const code = Number(rpc[1]);
507
+ const isStandard = [-32700, -32600, -32601, -32602, -32603].includes(code);
508
+ // A session-initialize error never reached the tool, whatever its code.
509
+ if (/session initialize/.test(message)) return { outcome: "unexercised", detail };
510
+ return isStandard ? { outcome: "unexercised", detail } : { outcome: "refused", detail };
511
+ }
512
+
513
+ // HTTP status on the tools/call: an authorisation status is a decision.
514
+ const http = message.match(/^Server-tool call .* failed (\d{3}):/);
515
+ if (http) {
516
+ const status = Number(http[1]);
517
+ return status === 401 || status === 403
518
+ ? { outcome: "refused", detail }
519
+ : { outcome: "unexercised", detail };
520
+ }
521
+
522
+ // Bridge not configured, session initialise failure, transport error.
523
+ return { outcome: "unexercised", detail };
524
+ }
525
+
526
+ /** Builds a live check result, carrying what was observed. */
527
+ function liveCheck(id, title, findings, observed = null, { skipped = false, notApplicable = false, skipReason = "" } = {}) {
528
+ const status = notApplicable ? NOT_APPLICABLE : skipped ? SKIPPED : findings.length === 0 ? PASS : FAIL;
529
+ return {
530
+ id,
531
+ title,
532
+ status,
533
+ findings: (skipped || notApplicable) && skipReason ? [skipReason] : findings,
534
+ observed,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Verify a running target against the same claims as the static half.
540
+ *
541
+ * The three `probe_*` checks are deliberately inverted: they attempt something
542
+ * a governed principal must NOT be able to do, and pass only when the target
543
+ * refuses. A served probe is the finding.
544
+ *
545
+ * Nothing here writes content: the write probes target a non-existent id and
546
+ * a governed stack refuses on policy before persistence. Run against a
547
+ * non-production environment first.
548
+ *
549
+ * @param {object} site Resolved site config (with oauth.clientSecret resolved).
550
+ * @param {{transport: Function, now?: () => Date}} deps
551
+ * `transport` is fetch-shaped and injectable so this is testable offline.
552
+ * @returns {Promise<object>} Evidence document. Never contains secrets or payloads.
553
+ */
554
+ export async function verifyLive(site, { transport, callTool = null, contentTarget = null, contentTargetType = null, now = () => new Date() }) {
555
+ const baseUrl = String(site?.baseUrl ?? "");
556
+ const checks = [];
557
+ const httpsOk = baseUrl.startsWith("https://") || isLoopback(baseUrl);
558
+
559
+ // --- transport -----------------------------------------------------------
560
+ const health = httpsOk ? await attempt(transport, joinUrl(baseUrl, "/drupal-mcp/health")) : null;
561
+ checks.push(
562
+ liveCheck(
563
+ "transport",
564
+ "The target answers over an encrypted transport",
565
+ (() => {
566
+ if (!httpsOk) return [`baseUrl is not HTTPS (${baseUrl || "missing"}).`];
567
+ if (health.error) return [`the target could not be reached: ${health.error}`];
568
+ if (health.status === null) return ["the target returned no status."];
569
+ return [];
570
+ })(),
571
+ health && { status: health.status },
572
+ ),
573
+ );
574
+
575
+ // --- principal authentication -------------------------------------------
576
+ const oauth = site?.oauth;
577
+ let token = null;
578
+ if (!oauth?.clientId || !oauth?.clientSecret) {
579
+ checks.push(
580
+ liveCheck("principal_auth", "The principal authenticates, and anonymous access is refused", [], null, {
581
+ skipped: true,
582
+ skipReason: "no OAuth principal is configured for this site; nothing to authenticate as.",
583
+ }),
584
+ );
585
+ } else {
586
+ // Mint ONCE. Two mints made the check pass on the first while the second
587
+ // silently yielded no token, leaving every later call unauthenticated —
588
+ // so the negative probes would "pass" on 401s instead of real refusals.
589
+ const tokenForm = new URLSearchParams({
590
+ grant_type: oauth.grant ?? "client_credentials",
591
+ client_id: oauth.clientId,
592
+ client_secret: oauth.clientSecret,
593
+ scope: (oauth.scopes ?? []).join(" "),
594
+ }).toString();
595
+ let tokenStatus = null;
596
+ let tokenError = null;
597
+ try {
598
+ const response = await transport(joinUrl(baseUrl, oauth.tokenUrl ?? "/oauth/token"), {
599
+ method: "POST",
600
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
601
+ body: tokenForm,
602
+ });
603
+ tokenStatus = response.status ?? null;
604
+ if (response.ok) {
605
+ let body = null;
606
+ try {
607
+ body = await response.json();
608
+ } catch {
609
+ body = null;
610
+ }
611
+ const minted = body?.access_token;
612
+ if (typeof minted === "string" && minted !== "") {
613
+ token = minted;
614
+ }
615
+ }
616
+ } catch (err) {
617
+ tokenError = String(err?.message ?? err);
618
+ }
619
+ const anonymous = await attempt(transport, joinUrl(baseUrl, "/drupal-mcp/readiness"));
620
+ checks.push(
621
+ liveCheck(
622
+ "principal_auth",
623
+ "The principal authenticates, and anonymous access is refused",
624
+ (() => {
625
+ const findings = [];
626
+ if (!token) {
627
+ findings.push(
628
+ `the principal did not obtain a usable access token (status ${tokenStatus ?? "none"}` +
629
+ `${tokenError ? `, ${tokenError}` : ""}).`,
630
+ );
631
+ }
632
+ if (anonymous.ok) {
633
+ findings.push("a governed path answered an anonymous request; authentication is not being enforced.");
634
+ }
635
+ return findings;
636
+ })(),
637
+ { tokenStatus, anonymousStatus: anonymous.status },
638
+ ),
639
+ );
640
+ }
641
+
642
+ const authorized = token ? { Authorization: `Bearer ${token}` } : {};
643
+
644
+ // Without a usable token, an authenticated check proves nothing: a refusal
645
+ // would be a 401, not a policy decision. Skip rather than claim either way.
646
+ const unauthenticated = Boolean(oauth?.clientId && oauth?.clientSecret) && !token;
647
+ const noTokenSkip = {
648
+ skipped: true,
649
+ skipReason: "the principal has no usable access token, so a refusal here would prove nothing about policy.",
650
+ };
651
+
652
+ // --- source governance ---------------------------------------------------
653
+ if (unauthenticated) {
654
+ checks.push(liveCheck("source_governance", "The source governance contract verifies", [], null, noTokenSkip));
655
+ } else if (site?.requireGovernance !== true) {
656
+ checks.push(
657
+ liveCheck("source_governance", "The source governance contract verifies", [], null, {
658
+ notApplicable: true,
659
+ skipReason: "the site does not declare requireGovernance; there is no contract to verify.",
660
+ }),
661
+ );
662
+ } else {
663
+ const readiness = await attempt(transport, joinUrl(baseUrl, "/drupal-mcp/readiness"), { headers: authorized });
664
+ checks.push(
665
+ liveCheck(
666
+ "source_governance",
667
+ "The source governance contract verifies",
668
+ readiness.ok
669
+ ? []
670
+ : [
671
+ `the source reports the contract is not ready (status ${readiness.status ?? "none"}` +
672
+ `${readiness.reason ? `, reason ${readiness.reason}` : ""}).`,
673
+ ],
674
+ { status: readiness.status, reason: readiness.reason },
675
+ ),
676
+ );
677
+ }
678
+
679
+ // --- entitlement filtering ----------------------------------------------
680
+ // Through the connector's own bridge client, so the probe exercises the real
681
+ // contract: an MCP session, the governed tool's `tool_api.*` name and its
682
+ // argument shape, and a refusal surfaced as a tool/JSON-RPC error rather
683
+ // than an HTTP status. A hand-rolled JSON-RPC body would "pass" for the
684
+ // wrong reason — the server would reject it as malformed, not as denied.
685
+ const scopes = site?.oauth?.scopes ?? [];
686
+ const holdsConfigScope = scopes.includes("mcp_config");
687
+
688
+ /**
689
+ * Attempts a governed config write. Returns {served, detail}: `served` true
690
+ * means the write was accepted, which for an out-of-tier principal is the
691
+ * finding.
692
+ */
693
+ const attemptConfigWrite = async () => {
694
+ try {
695
+ await callTool(site, CONFIG_SET_TOOL, { name: "system.site", data: { name: "verification probe" } });
696
+ return { served: true, outcome: "served", detail: "accepted" };
697
+ } catch (err) {
698
+ // Not every throw is a refusal — see classifyBridgeError.
699
+ const { outcome, detail } = classifyBridgeError(err);
700
+ return { served: false, outcome, detail };
701
+ }
702
+ };
703
+
704
+ let configWrite = null;
705
+ if (unauthenticated) {
706
+ checks.push(liveCheck("entitlement_filtering", "Out-of-tier operations are filtered for this principal", [], null, noTokenSkip));
707
+ } else if (holdsConfigScope) {
708
+ checks.push(
709
+ liveCheck("entitlement_filtering", "Out-of-tier operations are filtered for this principal", [], null, {
710
+ notApplicable: true,
711
+ skipReason: "this principal holds mcp_config, so a config write is in tier; run the probe with a content-tier principal.",
712
+ }),
713
+ );
714
+ } else if (typeof callTool !== "function" || !site?.serverTools?.url) {
715
+ checks.push(
716
+ liveCheck("entitlement_filtering", "Out-of-tier operations are filtered for this principal", [], null, {
717
+ notApplicable: true,
718
+ skipReason: "no governed tool bridge is configured for this site (serverTools.url), so there is no config surface to deny.",
719
+ }),
720
+ );
721
+ } else {
722
+ configWrite = await attemptConfigWrite();
723
+ checks.push(
724
+ configWrite.outcome === "unexercised"
725
+ ? liveCheck("entitlement_filtering", "Out-of-tier operations are filtered for this principal", [], configWrite, {
726
+ skipped: true,
727
+ skipReason: `the governed tool call never ran, so no policy decision was observed: ${configWrite.detail}`,
728
+ })
729
+ : liveCheck(
730
+ "entitlement_filtering",
731
+ "Out-of-tier operations are filtered for this principal",
732
+ configWrite.served
733
+ ? ["a config write was served to a principal that does not hold the mcp_config scope."]
734
+ : [],
735
+ configWrite,
736
+ ),
737
+ );
738
+ }
739
+
740
+ // --- target resolution ---------------------------------------------------
741
+ if (unauthenticated) {
742
+ checks.push(liveCheck("target_resolution", "The site resolves to exactly one target that describes itself", [], null, noTokenSkip));
743
+ } else {
744
+ const context = await attempt(transport, joinUrl(baseUrl, "/drupal-mcp/context"), { headers: authorized });
745
+ checks.push(
746
+ liveCheck(
747
+ "target_resolution",
748
+ "The site resolves to exactly one target that describes itself",
749
+ context.ok ? [] : [`the target did not return its context document (status ${context.status ?? "none"}).`],
750
+ { status: context.status },
751
+ ),
752
+ );
753
+ }
754
+
755
+ // --- negative probes -----------------------------------------------------
756
+ if (unauthenticated) {
757
+ for (const [id, title] of [
758
+ ["probe_mass_read", `A ${MASS_READ_LIMIT}-item read is refused or bounded`],
759
+ ["probe_config_change", "A configuration change is refused"],
760
+ ["probe_content_edit", "An edit to live content is refused"],
761
+ ]) {
762
+ checks.push(liveCheck(id, title, [], null, noTokenSkip));
763
+ }
764
+ } else {
765
+ const massRead = await attempt(
766
+ transport,
767
+ `${joinUrl(baseUrl, "/jsonapi/node/article")}?page%5Blimit%5D=${MASS_READ_LIMIT}`,
768
+ { headers: authorized },
769
+ );
770
+ checks.push(
771
+ (() => {
772
+ const observed = { status: massRead.status, codes: massRead.codes, items: massRead.count };
773
+ const title = `A ${MASS_READ_LIMIT}-item read is refused or bounded`;
774
+ // Refused outright: the control fired.
775
+ if (!massRead.ok) return liveCheck("probe_mass_read", title, [], observed);
776
+ // Served, but bounded well below what was asked for: also the control
777
+ // firing — a cap is a bound, and reporting it as unbounded would train
778
+ // operators to ignore the verifier.
779
+ if (massRead.count !== null && massRead.count < MASS_READ_LIMIT) {
780
+ return liveCheck("probe_mass_read", title, [], observed);
781
+ }
782
+ if (massRead.count === null) {
783
+ return liveCheck("probe_mass_read", title, [], observed, {
784
+ skipped: true,
785
+ skipReason: "the read succeeded but its size could not be measured, so neither a bound nor an unbounded read is proven.",
786
+ });
787
+ }
788
+ return liveCheck(
789
+ "probe_mass_read",
790
+ title,
791
+ [`an unbounded read was served (status ${massRead.status}, ${massRead.count} items); the source is not bounding this principal's reads.`],
792
+ observed,
793
+ );
794
+ })(),
795
+ );
796
+
797
+ const canAttemptWrite = scopes.length === 0 || scopes.includes("mcp_write") || scopes.includes("mcp_config");
798
+
799
+ // The config probe is only a NEGATIVE probe for a principal that must not
800
+ // write config. A developer or break-glass role legitimately holds
801
+ // mcp_config, and failing its healthy run would be a false finding.
802
+ if (holdsConfigScope) {
803
+ checks.push(
804
+ liveCheck("probe_config_change", "A configuration change is refused", [], null, {
805
+ notApplicable: true,
806
+ skipReason: "this principal holds mcp_config: a served config write is in tier here, so the probe proves nothing. Run it with a content-tier principal.",
807
+ }),
808
+ );
809
+ } else if (typeof callTool !== "function" || !site?.serverTools?.url) {
810
+ checks.push(
811
+ liveCheck("probe_config_change", "A configuration change is refused", [], null, {
812
+ notApplicable: true,
813
+ skipReason: "no governed tool bridge is configured for this site (serverTools.url), so there is no config surface to deny.",
814
+ }),
815
+ );
816
+ } else {
817
+ const attemptResult = configWrite ?? (await attemptConfigWrite());
818
+ checks.push(
819
+ attemptResult.outcome === "unexercised"
820
+ ? liveCheck("probe_config_change", "A configuration change is refused", [], attemptResult, {
821
+ skipped: true,
822
+ skipReason: `the config write never reached a policy decision, so its refusal proves nothing: ${attemptResult.detail}`,
823
+ })
824
+ : liveCheck(
825
+ "probe_config_change",
826
+ "A configuration change is refused",
827
+ attemptResult.served ? ["a configuration write was accepted."] : [],
828
+ attemptResult,
829
+ ),
830
+ );
831
+ }
832
+
833
+ if (!canAttemptWrite) {
834
+ checks.push(
835
+ liveCheck("probe_content_edit", "An edit to live content is refused", [], null, {
836
+ notApplicable: true,
837
+ skipReason: "this principal holds no write scope, so there is no write gate for it to cross.",
838
+ }),
839
+ );
840
+ } else if (!contentTarget) {
841
+ // A PATCH at an id that does not exist returns 404 before any access
842
+ // check runs, so scoring it as a refusal would claim the publish gate
843
+ // holds without ever reaching it. The probe needs a real target.
844
+ checks.push(
845
+ liveCheck("probe_content_edit", "An edit to live content is refused", [], null, {
846
+ skipped: true,
847
+ skipReason:
848
+ "no content target supplied (--content-target <uuid>), so the publish gate was never reached. " +
849
+ "Point it at a node on a non-production target that you would not mind being published if the gate fails.",
850
+ }),
851
+ );
852
+ } else {
853
+ const targetType = contentTargetType ?? "node--article";
854
+ const [entityType, bundle] = targetType.split("--");
855
+ const contentEdit = await attempt(
856
+ transport,
857
+ joinUrl(baseUrl, `/jsonapi/${entityType}/${bundle ?? entityType}/${contentTarget}`),
858
+ {
859
+ method: "PATCH",
860
+ headers: { ...authorized, "Content-Type": "application/vnd.api+json" },
861
+ body: JSON.stringify({
862
+ // Publish-bearing and otherwise value-preserving: the flag under
863
+ // test is `status`, so a refusal proves the gate and an acceptance
864
+ // changes nothing else about the node.
865
+ data: { type: targetType, id: contentTarget, attributes: { status: true } },
866
+ }),
867
+ },
868
+ );
869
+ const observed = { status: contentEdit.status, codes: contentEdit.codes, target: contentTarget };
870
+ checks.push(
871
+ (() => {
872
+ const title = "An edit to live content is refused";
873
+ if (contentEdit.ok) {
874
+ return liveCheck(
875
+ "probe_content_edit",
876
+ title,
877
+ [`a publish-bearing edit was ACCEPTED (status ${contentEdit.status}); the no-agent-publish floor did not hold, and the target may now be published.`],
878
+ observed,
879
+ );
880
+ }
881
+ // Only an authorisation decision proves the gate. "Not found",
882
+ // "unprocessable" and server errors never reached it.
883
+ if (contentEdit.status === 403 || contentEdit.status === 401) {
884
+ return liveCheck("probe_content_edit", title, [], observed);
885
+ }
886
+ return liveCheck("probe_content_edit", title, [], observed, {
887
+ skipped: true,
888
+ skipReason: `the edit did not reach the publish gate (status ${contentEdit.status ?? "none"}), so its refusal proves nothing; check the target exists and is of the given type.`,
889
+ });
890
+ })(),
891
+ );
892
+ }
893
+ }
894
+
895
+ const counts = checks.reduce(
896
+ (acc, c) => ({ ...acc, [c.status]: acc[c.status] + 1 }),
897
+ { [PASS]: 0, [FAIL]: 0, [SKIPPED]: 0, [NOT_APPLICABLE]: 0 },
898
+ );
899
+
900
+ return {
901
+ tool: "drupal-mcp-connector verify",
902
+ mode: "live",
903
+ connectorVersion: CLIENT_VERSION,
904
+ generatedAt: now().toISOString(),
905
+ subject: { site: site?._name ?? null, host: hostOf(baseUrl), scopes: site?.oauth?.scopes ?? [] },
906
+ checks,
907
+ residuals: RESIDUALS,
908
+ summary: {
909
+ pass: counts[PASS],
910
+ fail: counts[FAIL],
911
+ skipped: counts[SKIPPED],
912
+ notApplicable: counts[NOT_APPLICABLE],
913
+ ok: counts[FAIL] === 0 && counts[SKIPPED] === 0,
914
+ },
915
+ };
916
+ }