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.
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-update-media.md +4 -4
- package/CHANGELOG.md +78 -0
- package/README.md +1 -0
- package/bin/drupal-mcp-verify.js +147 -0
- package/config/config.example.json +31 -29
- package/package.json +7 -4
- package/src/lib/canonical.js +48 -0
- package/src/lib/entity-response.js +41 -0
- package/src/lib/security.js +27 -4
- package/src/lib/verify.js +916 -0
- package/src/tools/entities.js +20 -4
- package/src/tools/media.js +34 -12
- package/src/tools/nodes.js +18 -5
|
@@ -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
|
+
}
|