drupal-mcp-connector 2.19.0 → 2.20.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.
Files changed (70) hide show
  1. package/.agents/commands/drupal-config-set.md +2 -2
  2. package/.agents/commands/drupal-create-node.md +2 -2
  3. package/.agents/commands/drupal-create-translation.md +1 -1
  4. package/.agents/commands/drupal-delete-node.md +1 -1
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-drush-config-import.md +2 -2
  7. package/.agents/commands/drupal-drush-module-disable.md +2 -2
  8. package/.agents/commands/drupal-drush-module-list.md +1 -1
  9. package/.agents/commands/drupal-drush-user-list.md +1 -1
  10. package/.agents/commands/drupal-drush-watchdog.md +1 -1
  11. package/.agents/commands/drupal-entity-create.md +2 -2
  12. package/.agents/commands/drupal-entity-delete.md +1 -1
  13. package/.agents/commands/drupal-entity-update.md +4 -4
  14. package/.agents/commands/drupal-report-field-completeness.md +2 -2
  15. package/.agents/commands/drupal-report-missing-field.md +2 -2
  16. package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
  17. package/.agents/commands/drupal-report-status-report.md +1 -1
  18. package/.agents/commands/drupal-update-node.md +4 -4
  19. package/CHANGELOG.md +254 -0
  20. package/README.md +10 -3
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +52 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +40 -5
  25. package/scripts/install-commands.js +148 -11
  26. package/src/index.js +9 -12
  27. package/src/lib/backends/graphql-schema.js +9 -1
  28. package/src/lib/backends/graphql.js +4 -2
  29. package/src/lib/backends/index.js +9 -3
  30. package/src/lib/backends/jsonapi.js +2 -0
  31. package/src/lib/dispatch.js +6 -6
  32. package/src/lib/drupal-fetch.js +97 -26
  33. package/src/lib/dry-run-checks.js +78 -0
  34. package/src/lib/error-body.js +448 -0
  35. package/src/lib/error-status.js +38 -0
  36. package/src/lib/errors.js +0 -11
  37. package/src/lib/evidence.js +0 -6
  38. package/src/lib/governance.js +2 -8
  39. package/src/lib/link-checker.js +3 -3
  40. package/src/lib/mcp-server.js +7 -1
  41. package/src/lib/metatag-audit.js +2 -1
  42. package/src/lib/module-tools.js +23 -2
  43. package/src/lib/operations.js +2 -2
  44. package/src/lib/patch-preflight.js +23 -5
  45. package/src/lib/policy-enforcement.js +4 -4
  46. package/src/lib/principal.js +3 -3
  47. package/src/lib/relay/edge.js +2 -2
  48. package/src/lib/reports-support.js +75 -0
  49. package/src/lib/security.js +234 -18
  50. package/src/lib/sentinel-draft.js +3 -2
  51. package/src/lib/server-tools.js +188 -27
  52. package/src/lib/tool-prompts.js +139 -8
  53. package/src/lib/usage.js +0 -9
  54. package/src/lib/verify.js +164 -61
  55. package/src/tools/config.js +70 -5
  56. package/src/tools/drush.js +191 -17
  57. package/src/tools/entities.js +18 -6
  58. package/src/tools/fields.js +40 -4
  59. package/src/tools/graphql.js +10 -5
  60. package/src/tools/nodes.js +16 -6
  61. package/src/tools/paragraphs.js +1 -1
  62. package/src/tools/reports-config.js +3 -3
  63. package/src/tools/reports-content.js +34 -26
  64. package/src/tools/reports-extra.js +46 -38
  65. package/src/tools/reports.js +50 -25
  66. package/src/tools/scheduler.js +1 -1
  67. package/src/tools/structure.js +12 -2
  68. package/src/tools/translations.js +5 -1
  69. package/src/lib/draft-write.js +0 -19
  70. package/src/lib/node-draft-inventory.js +0 -5
@@ -24,7 +24,9 @@
24
24
 
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
+ import { httpStatusOf } from "./error-status.js";
27
28
  import { writeDraft, readNodeDraftInventory, assertInventoryDraftLanguage } from "./sentinel-draft.js";
29
+ import { PREFLIGHT_NONE, PREFLIGHT_CORE_GUARD, PREFLIGHT_SENTINEL_DRAFT } from "./dry-run-checks.js";
28
30
 
29
31
  /** Stable error code for a core working-copy / not-latest-revision block. */
30
32
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
@@ -300,7 +302,7 @@ const ID_MISMATCH_RE = /does not match the ID in the payload/i;
300
302
  */
301
303
  export function isProbePassedWithoutSave(err) {
302
304
  const msg = String(err?.message || "");
303
- return ID_MISMATCH_RE.test(msg) || /Drupal 422\b/.test(msg);
305
+ return ID_MISMATCH_RE.test(msg) || httpStatusOf(err) === 422;
304
306
  }
305
307
 
306
308
  /**
@@ -315,6 +317,12 @@ export function isProbePassedWithoutSave(err) {
315
317
  * When `resourceVersion` is `rel:working-copy`, the probe hits that same
316
318
  * URL so dryRun cannot succeed when the real write would 400 (#166).
317
319
  *
320
+ * The probe body carries no attributes, and core rejects the id before it
321
+ * evaluates field access or validation. A passing probe therefore proves the
322
+ * route's entity update access and the working-copy guard, and nothing about
323
+ * the fields the real write will submit (#336). The result says so
324
+ * (`payloadEvaluated: false`) rather than claiming the target is writable.
325
+ *
318
326
  * @param {object} args
319
327
  * @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
320
328
  * @param {string} args.entityType
@@ -323,7 +331,7 @@ export function isProbePassedWithoutSave(err) {
323
331
  * @param {?object} [args.existing]
324
332
  * @param {object} [args.attributes]
325
333
  * @param {?string} [args.resourceVersion]
326
- * @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
334
+ * @returns {Promise<{probed: boolean, revisionGuardPassed?: boolean, payloadEvaluated?: boolean, skipped?: string}>}
327
335
  * @throws {PatchBlockedError|WorkingCopyStaleError} When the guard rejects the probe.
328
336
  */
329
337
  export async function preflightPatchWritable({
@@ -368,7 +376,7 @@ export async function preflightPatchWritable({
368
376
  throw new PatchBlockedError(cause);
369
377
  }
370
378
  if (isProbePassedWithoutSave(err)) {
371
- return { probed: true, writable: true };
379
+ return { probed: true, revisionGuardPassed: true, payloadEvaluated: false };
372
380
  }
373
381
  throw err;
374
382
  }
@@ -380,9 +388,14 @@ export async function preflightPatchWritable({
380
388
  * `langcode` always resolves Sentinel inventory and draft-preflights, even
381
389
  * when the entity is unmoderated (media translations).
382
390
  *
391
+ * The returned `preflight` names the server-side check that ran, so a dryRun
392
+ * preview can state what it covered (#336; see dry-run-checks.js):
393
+ * `sentinel_draft` evaluated the submitted fields, `core_patch_guard` carried
394
+ * no fields, `none` means Drupal evaluated nothing.
395
+ *
383
396
  * @param {object} backend
384
397
  * @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object, relationships?: object, langcode?: string}} args
385
- * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
398
+ * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string, preflight: string}>}
386
399
  */
387
400
  export async function prepareGuardedPatch(backend, {
388
401
  entityType, bundle, id, existing, attributes, relationships, langcode,
@@ -391,6 +404,8 @@ export async function prepareGuardedPatch(backend, {
391
404
  const target = (needsPreflight || langcode)
392
405
  ? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
393
406
  : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
407
+ // Set only after a check has run and passed; a refusal throws first.
408
+ target.preflight = PREFLIGHT_NONE;
394
409
  if (needsPreflight && !target.resourceVersion) {
395
410
  // Canonical path (no distinct working copy). The id-mismatch probe never
396
411
  // reaches Sentinel's save-time stale-default check; refuse here when the
@@ -427,6 +442,7 @@ export async function prepareGuardedPatch(backend, {
427
442
  } catch (err) {
428
443
  throw rewriteStaleCopyError(err);
429
444
  }
445
+ target.preflight = PREFLIGHT_SENTINEL_DRAFT;
430
446
  return target;
431
447
  }
432
448
  if (target.resourceVersion) {
@@ -438,13 +454,15 @@ export async function prepareGuardedPatch(backend, {
438
454
  } catch (err) {
439
455
  throw rewriteStaleCopyError(err);
440
456
  }
457
+ target.preflight = PREFLIGHT_SENTINEL_DRAFT;
441
458
  return target;
442
459
  }
443
460
  try {
444
- await preflightPatchWritable({
461
+ const probe = await preflightPatchWritable({
445
462
  backend, entityType, bundle, id, existing, attributes,
446
463
  resourceVersion: target.resourceVersion,
447
464
  });
465
+ if (probe.probed === true) target.preflight = PREFLIGHT_CORE_GUARD;
448
466
  } catch (err) {
449
467
  throw rewriteStaleCopyError(err);
450
468
  }
@@ -11,11 +11,11 @@
11
11
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
12
12
  import { SEAL_PREFIX } from "./policy-promotion.js";
13
13
 
14
- export const POLICY_BUNDLE_VERSION = 1;
14
+ const POLICY_BUNDLE_VERSION = 1;
15
15
 
16
- export const DEFAULT_BUNDLE_TTL = 86400 * 30;
16
+ const DEFAULT_BUNDLE_TTL = 86400 * 30;
17
17
 
18
- export const EMERGENCY_DENY = "*";
18
+ const EMERGENCY_DENY = "*";
19
19
 
20
20
  export const EMERGENCY_DIGEST = "emergency-deny";
21
21
 
@@ -43,7 +43,7 @@ function normalize(value) {
43
43
  * @param {object} claims
44
44
  * @returns {string}
45
45
  */
46
- export function canonicalJson(claims) {
46
+ function canonicalJson(claims) {
47
47
  return JSON.stringify(normalize(claims));
48
48
  }
49
49
 
@@ -15,6 +15,7 @@
15
15
  import { AsyncLocalStorage } from "node:async_hooks";
16
16
  import { getDefaultSiteName, getInboundGrants } from "./config.js";
17
17
  import { inferOperation } from "./operations.js";
18
+ import { POLICY_DIGEST } from "./policy-promotion.js";
18
19
  import { resolveSecurityConfig, SecurityError } from "./security.js";
19
20
 
20
21
  const identityStore = new AsyncLocalStorage();
@@ -23,7 +24,6 @@ const identityStore = new AsyncLocalStorage();
23
24
  export const TARGET_HINT_KEYS = Object.freeze(["site", "environment", "tenant", "target"]);
24
25
 
25
26
  const ACTOR_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
26
- const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
27
27
 
28
28
  function tableHasKeys(table) {
29
29
  return Object.keys(table).some((key) => {
@@ -129,7 +129,7 @@ function grantNameList(values) {
129
129
  * @param {object|null} actors
130
130
  * @returns {object|null}
131
131
  */
132
- export function normalizeActors(actors) {
132
+ function normalizeActors(actors) {
133
133
  if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
134
134
  const entries = [];
135
135
  for (const [rawKey, value] of Object.entries(actors)) {
@@ -201,7 +201,7 @@ export function resolveActor({ identity = null, actors = null } = {}) {
201
201
  * @param {object|null} policies
202
202
  * @returns {object|null}
203
203
  */
204
- export function normalizePolicies(policies) {
204
+ function normalizePolicies(policies) {
205
205
  if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
206
206
  const entries = [];
207
207
  for (const [rawKey, value] of Object.entries(policies)) {
@@ -348,7 +348,7 @@ function callerApprovalHint(args = {}) {
348
348
  * @param {unknown} raw
349
349
  * @returns {string[]|{invalid: true, reason: string}}
350
350
  */
351
- export function normalizeApprovalRequiredTools(raw) {
351
+ function normalizeApprovalRequiredTools(raw) {
352
352
  if (raw === null || raw === undefined) return [];
353
353
  if (Array.isArray(raw)) {
354
354
  return [...new Set(
@@ -415,7 +415,7 @@ export class EdgeStartupError extends Error {
415
415
  * @param {Record<string, string|string[]>} [headers]
416
416
  * @returns {Record<string, string>}
417
417
  */
418
- export function fanDownHeaders(headers = {}) {
418
+ function fanDownHeaders(headers = {}) {
419
419
  const entries = [];
420
420
  for (const [name, value] of Object.entries(forwardHeaders(headers))) {
421
421
  const key = String(name).toLowerCase();
@@ -64,6 +64,35 @@ export function fieldValue(entity, candidates) {
64
64
  return undefined;
65
65
  }
66
66
 
67
+ /**
68
+ * Read a field off a canonical entity and say whether its KEY was there.
69
+ *
70
+ * JSON:API keeps the key of an empty field (value `null`, `[]`, or
71
+ * `data: null`) and leaves out the key of a field the account may not view.
72
+ * So an absent key and an empty value are different facts, and callers that
73
+ * count "missing" values must keep them apart (#337).
74
+ *
75
+ * Looks in `fields`, then `relationships`. Promoted base attributes (`title`,
76
+ * `status`, `langcode`, `created`, `changed`, and `path` as `url`) are always
77
+ * carried by the canonical shape, so they always read as present: a denied
78
+ * base attribute cannot be told from an empty one after promotion.
79
+ *
80
+ * @param {object} entity Canonical entity.
81
+ * @param {string} field Field machine name.
82
+ * @returns {{present: boolean, value: *}} `present` is about the key, not the value.
83
+ */
84
+ export function fieldPresence(entity, field) {
85
+ const name = field === "path" ? "url" : field;
86
+ if (BASE_KEYS.has(name)) {
87
+ return { present: true, value: new Map(Object.entries(entity ?? {})).get(name) };
88
+ }
89
+ const fields = new Map(Object.entries(entity?.fields ?? {}));
90
+ if (fields.has(name)) return { present: true, value: fields.get(name) };
91
+ const relationships = new Map(Object.entries(entity?.relationships ?? {}));
92
+ if (relationships.has(name)) return { present: true, value: relationships.get(name) };
93
+ return { present: false, value: undefined };
94
+ }
95
+
67
96
  /**
68
97
  * Whole days elapsed between a date and now.
69
98
  * @param {?(string|number|Date)} dateValue A date parseable by `new Date()`.
@@ -73,3 +102,49 @@ export function daysSince(dateValue) {
73
102
  if (!dateValue) return null;
74
103
  return Math.floor((Date.now() - new Date(dateValue).getTime()) / MS_PER_DAY);
75
104
  }
105
+
106
+ /**
107
+ * Whether a canonical field or relationship value carries no content.
108
+ *
109
+ * Handles scalars, `{value}` text objects, arrays, relationship refs
110
+ * (`{id}` / `[{id}, …]` / `null`), and keyed objects such as a link
111
+ * (`{uri, title}`). A keyed object with at least one key and none of the
112
+ * known value keys counts as populated.
113
+ *
114
+ * @param {*} value A value read off an entity's base props, `fields`, or `relationships`.
115
+ * @returns {boolean} True when the value is absent or carries no content.
116
+ */
117
+ export function isEmptyFieldValue(value) {
118
+ if (value === undefined || value === null || value === "") return true;
119
+ if (Array.isArray(value)) return value.every((item) => isEmptyFieldValue(item));
120
+ if (typeof value === "object") {
121
+ if ("id" in value) return !value.id;
122
+ if ("value" in value) return value.value === undefined || value.value === null || value.value === "";
123
+ if ("target_id" in value) return value.target_id === undefined || value.target_id === null;
124
+ if ("uri" in value) return !value.uri;
125
+ return Object.keys(value).length === 0;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Note attached to a report's `notVisible` list: requested fields whose key is
132
+ * absent from every sampled entity, so the report could not score them (#341).
133
+ */
134
+ export const FIELDS_NOT_VISIBLE_NOTE =
135
+ "These fields are absent from every sampled entity (the key is missing, not empty). " +
136
+ "Each may be denied to this account or not exist on this bundle, or the name may be wrong. " +
137
+ "Drupal leaves a field the account may not view out of the response with no marker, so this report cannot tell whether any value is missing. " +
138
+ "They were not scored.";
139
+
140
+ /**
141
+ * Normalize a caller's `fields` argument to distinct, non-empty field names.
142
+ * Arguments are not schema-validated before they reach a handler, so a single
143
+ * string is read as one name and non-string entries are dropped.
144
+ * @param {*} fields The raw `fields` argument.
145
+ * @returns {string[]} Field names, or [] when the caller named none.
146
+ */
147
+ export function requestedFieldNames(fields) {
148
+ const list = typeof fields === "string" ? [fields] : (Array.isArray(fields) ? fields : []);
149
+ return [...new Set(list.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()))];
150
+ }
@@ -53,6 +53,19 @@ import { parse } from "graphql";
53
53
  *
54
54
  * globalRedactedFields string[] stripped from every response, every type
55
55
  *
56
+ * protectedModules string[] module machine names ADDED to the default
57
+ * protected list (DEFAULT_PROTECTED_MODULES).
58
+ * drupal_drush_module_disable refuses them (#346).
59
+ * allowProtectedModuleUninstall string[] explicit opt-out: names REMOVED from the
60
+ * protected list. The only way to shrink it.
61
+ * A malformed value in either key refuses
62
+ * every uninstall rather than being ignored.
63
+ * allowCoreExtensionChange boolean false on every preset. true lets
64
+ * drupal_config_set write core.extension and
65
+ * lets drupal_drush_config_import run when
66
+ * core.extension differs (#349). Anything but
67
+ * true or false keeps both refused.
68
+ *
56
69
  * declaredCeiling string narrow-only X-MCP-Declared-Ceiling
57
70
  * (public|internal|restricted). Invalid
58
71
  * values are dropped, never widened.
@@ -244,6 +257,98 @@ const PRESETS = {
244
257
  * @param {object} site Site config (reads site.security).
245
258
  * @returns {object} Effective security config used by the assert/redact helpers.
246
259
  */
260
+ /**
261
+ * Modules drupal_drush_module_disable refuses to uninstall, on every preset
262
+ * (#346). Uninstalling one removes a control the connector relies on, and
263
+ * Drupal drops the module's hook_schema tables on uninstall, so an audit log or
264
+ * stored keys go with it. Operators extend the list with
265
+ * `security.protectedModules` and shrink it only with the explicit
266
+ * `security.allowProtectedModuleUninstall`.
267
+ */
268
+ export const DEFAULT_PROTECTED_MODULES = Object.freeze([
269
+ // Governance and integrity.
270
+ "mcp_sentinel", "audit_chain", "field_guard", "file_gate",
271
+ // Secrets and authentication.
272
+ "key", "encrypt", "simple_oauth", "consumers",
273
+ // The API and governed tool surface the connector talks to.
274
+ "jsonapi", "serialization", "mcp_server", "mcp_server_tool_bridge", "tool",
275
+ // The editorial gate that decides publication.
276
+ "content_moderation", "workflows",
277
+ ]);
278
+
279
+ /** Drupal module machine name. Same rule as validateMachineName(), kept local to avoid an import cycle. */
280
+ const MODULE_NAME_RE = /^[a-z][a-z0-9_]{0,127}$/;
281
+
282
+ /**
283
+ * Read one module-list key from raw site security config.
284
+ * @param {object} raw site.security.
285
+ * @param {string} key Config key.
286
+ * @param {string[]} problems Collects a message per malformed key.
287
+ * @returns {string[]} The valid names (empty when the key is absent or malformed).
288
+ */
289
+ function readModuleList(raw, key, problems) {
290
+ const value = new Map(Object.entries(raw)).get(key);
291
+ if (value === undefined || value === null) return [];
292
+ if (!Array.isArray(value)) {
293
+ problems.push(`security.${key} must be an array of module machine names.`);
294
+ return [];
295
+ }
296
+ const valid = value.filter((name) => typeof name === "string" && MODULE_NAME_RE.test(name));
297
+ if (valid.length !== value.length) {
298
+ problems.push(
299
+ `security.${key} has ${value.length - valid.length} entr${value.length - valid.length === 1 ? "y that is" : "ies that are"} ` +
300
+ "not a module machine name (lowercase letters, digits and underscores)."
301
+ );
302
+ // A malformed opt-out never removes anything; a malformed extension still adds its valid names.
303
+ return key === "allowProtectedModuleUninstall" ? [] : valid;
304
+ }
305
+ return valid;
306
+ }
307
+
308
+ /**
309
+ * Resolve the effective protected-module list for a site.
310
+ * @param {object} raw site.security.
311
+ * @returns {{modules: string[], optOuts: string[], error: ?string}} `error` is
312
+ * set when either key is malformed; callers must then refuse every uninstall.
313
+ */
314
+ function resolveProtectedModules(raw) {
315
+ const problems = [];
316
+ const added = readModuleList(raw, "protectedModules", problems);
317
+ const optOuts = readModuleList(raw, "allowProtectedModuleUninstall", problems);
318
+ const removed = new Set(optOuts);
319
+ const modules = [...new Set([...DEFAULT_PROTECTED_MODULES, ...added])].filter((name) => !removed.has(name)).sort();
320
+ return { modules, optOuts: [...removed].sort(), error: problems.length ? problems.join(" ") : null };
321
+ }
322
+
323
+ /** The config object that lists installed modules and themes. */
324
+ export const CORE_EXTENSION_CONFIG = "core.extension";
325
+
326
+ /**
327
+ * Whether a config name addresses core.extension. Surrounding space and case
328
+ * are ignored: a source that trims the name would otherwise be reached by a
329
+ * padded one.
330
+ * @param {*} name Config object name.
331
+ * @returns {boolean}
332
+ */
333
+ export function isCoreExtensionConfig(name) {
334
+ return typeof name === "string" && name.trim().toLowerCase() === CORE_EXTENSION_CONFIG;
335
+ }
336
+
337
+ /**
338
+ * Resolve the explicit opt-in for changing core.extension (#349).
339
+ * @param {object} raw site.security.
340
+ * @returns {{allowed: boolean, error: ?string}} `error` is set when the value
341
+ * is neither true nor false; the change then stays refused.
342
+ */
343
+ function resolveCoreExtensionOptIn(raw) {
344
+ const value = new Map(Object.entries(raw)).get("allowCoreExtensionChange");
345
+ if (value === undefined || value === null) return { allowed: false, error: null };
346
+ if (typeof value !== "boolean") {
347
+ return { allowed: false, error: "security.allowCoreExtensionChange must be true or false." };
348
+ }
349
+ return { allowed: value, error: null };
350
+ }
351
+
247
352
  /** Default when `security.preset` is omitted — least privilege, not open mode (#140). */
248
353
  export const DEFAULT_SECURITY_PRESET = "production-strict";
249
354
 
@@ -254,6 +359,9 @@ export function resolveSecurityConfig(site) {
254
359
  const presetName = raw.preset ?? DEFAULT_SECURITY_PRESET;
255
360
  const preset = PRESETS[presetName] ?? PRESETS[DEFAULT_SECURITY_PRESET];
256
361
 
362
+ const protectedModules = resolveProtectedModules(raw);
363
+ const coreExtension = resolveCoreExtensionOptIn(raw);
364
+
257
365
  // Merge: explicit keys in site.security override the preset
258
366
  return {
259
367
  readOnly: raw.readOnly ?? preset.readOnly,
@@ -272,6 +380,13 @@ export function resolveSecurityConfig(site) {
272
380
  ],
273
381
  declaredCeiling: raw.declaredCeiling ?? preset.declaredCeiling,
274
382
  readBudgets: raw.readBudgets ?? preset.readBudgets ?? null,
383
+ // Not a preset value: the default list applies on every preset (#346).
384
+ protectedModules: protectedModules.modules,
385
+ protectedModuleOptOuts: protectedModules.optOuts,
386
+ protectedModulesError: protectedModules.error,
387
+ // Not a preset value either: strict on every preset (#349).
388
+ allowCoreExtensionChange: coreExtension.allowed,
389
+ coreExtensionChangeError: coreExtension.error,
275
390
  };
276
391
  }
277
392
 
@@ -387,7 +502,7 @@ export function hasScope(site, scope) {
387
502
  * @param {object} site Resolved site config.
388
503
  * @returns {boolean}
389
504
  */
390
- export function isGovernedSetup(site) {
505
+ function isGovernedSetup(site) {
391
506
  return site?.requireGovernance === true || Boolean(site?.oauth);
392
507
  }
393
508
 
@@ -430,6 +545,119 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
430
545
  }
431
546
  }
432
547
 
548
+ /**
549
+ * Gate a module uninstall (drupal_drush_module_disable) against the site's
550
+ * protected-module list (#346). Fails closed: a security config that is
551
+ * malformed, or that never went through resolveSecurityConfig(), refuses every
552
+ * uninstall.
553
+ * @param {object} secConfig Resolved security config.
554
+ * @param {string} moduleName Module machine name, already validated.
555
+ * @returns {void}
556
+ * @throws {SecurityError} if the module is protected or the list cannot be trusted.
557
+ */
558
+ export function assertModuleUninstallAllowed(secConfig, moduleName) {
559
+ if (secConfig?.protectedModulesError || !Array.isArray(secConfig?.protectedModules)) {
560
+ throw new SecurityError(
561
+ "Module uninstall is blocked because this site's protected-module config cannot be read. " +
562
+ `${secConfig?.protectedModulesError ?? "The protected-module list is missing."} ` +
563
+ "No module can be uninstalled through the connector until an operator fixes it."
564
+ );
565
+ }
566
+ if (secConfig.protectedModules.includes(moduleName)) {
567
+ throw new SecurityError(
568
+ `Module "${moduleName}" is protected and cannot be uninstalled through the connector. ` +
569
+ "Uninstalling it removes a control the connector relies on, and Drupal drops the module's tables on uninstall. " +
570
+ `To allow it, an operator must add "${moduleName}" to security.allowProtectedModuleUninstall for this site.`
571
+ );
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Gate a change to core.extension (#349). The object lists installed modules
577
+ * and themes, so a write to it, or a config import that changes it, can
578
+ * uninstall a protected module without going through
579
+ * assertModuleUninstallAllowed(). Refused on every preset unless the operator
580
+ * set `security.allowCoreExtensionChange: true`. Fails closed: a malformed
581
+ * value, or a config that never went through resolveSecurityConfig(), refuses.
582
+ * @param {object} secConfig Resolved security config.
583
+ * @param {string} refusal What is being refused and why, as full sentences.
584
+ * @returns {void}
585
+ * @throws {SecurityError} unless the operator opted in.
586
+ */
587
+ export function assertCoreExtensionChangeAllowed(secConfig, refusal) {
588
+ if (secConfig?.allowCoreExtensionChange === true && !secConfig.coreExtensionChangeError) return;
589
+ throw new SecurityError(
590
+ `${refusal} ` +
591
+ "To install or uninstall a module use drupal_drush_module_enable or drupal_drush_module_disable, " +
592
+ "which check the protected-module list. " +
593
+ (secConfig?.coreExtensionChangeError
594
+ ? `This site's opt-in cannot be read: ${secConfig.coreExtensionChangeError} It stays refused until an operator fixes it.`
595
+ : "To allow it, an operator must set security.allowCoreExtensionChange = true for this site.")
596
+ );
597
+ }
598
+
599
+ /**
600
+ * Check a drupal_config_set value for core.extension against the protected
601
+ * module list (#349). Runs only after assertCoreExtensionChangeAllowed().
602
+ *
603
+ * The value is a map of top-level keys, and the source also accepts a dotted
604
+ * key such as `module.devel`. A `module` key replaces the whole module map, so
605
+ * every protected module in `currentModules` must still be in it. A dotted key
606
+ * under `module.` that names a protected module is refused outright.
607
+ *
608
+ * @param {object} secConfig Resolved security config.
609
+ * @param {object} value The submitted map of config keys to values.
610
+ * @param {?string[]} currentModules Machine names installed now, or null when
611
+ * the caller has not read them. Needed only when `value` has a `module` key.
612
+ * @returns {{needsCurrentModules: boolean}} `needsCurrentModules` is true when
613
+ * the value replaces the module map and `currentModules` was not given; the
614
+ * caller reads the list and calls again.
615
+ * @throws {SecurityError} if the write would remove or alter a protected
616
+ * module, or the protected-module list cannot be trusted.
617
+ */
618
+ export function assertCoreExtensionValueKeepsProtected(secConfig, value, currentModules = null) {
619
+ if (secConfig?.protectedModulesError || !Array.isArray(secConfig?.protectedModules)) {
620
+ throw new SecurityError(
621
+ "The write to core.extension is blocked because this site's protected-module config cannot be read. " +
622
+ `${secConfig?.protectedModulesError ?? "The protected-module list is missing."}`
623
+ );
624
+ }
625
+ const entries = value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) : [];
626
+ const protectedSet = new Set(secConfig.protectedModules);
627
+
628
+ const dotted = entries
629
+ .map(([key]) => /^module\.([^.]+)/.exec(key)?.[1])
630
+ .filter((name) => name !== undefined && protectedSet.has(name));
631
+ if (dotted.length) {
632
+ throw new SecurityError(
633
+ `The write to core.extension changes the entry of protected module${dotted.length === 1 ? "" : "s"} ` +
634
+ `${[...new Set(dotted)].sort().join(", ")}. Nothing was written. ` +
635
+ "To allow it, an operator must name the module in security.allowProtectedModuleUninstall for this site."
636
+ );
637
+ }
638
+
639
+ const moduleEntry = entries.find(([key]) => key === "module");
640
+ if (!moduleEntry) return { needsCurrentModules: false };
641
+ const next = moduleEntry[1];
642
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
643
+ throw new SecurityError(
644
+ "The write to core.extension is refused: `module` must be a map of module machine names to weights. Nothing was written."
645
+ );
646
+ }
647
+ if (!Array.isArray(currentModules)) return { needsCurrentModules: true };
648
+
649
+ const kept = new Set(Object.keys(next));
650
+ const removed = currentModules.filter((name) => protectedSet.has(name) && !kept.has(name)).sort();
651
+ if (removed.length) {
652
+ throw new SecurityError(
653
+ `The write to core.extension would remove protected module${removed.length === 1 ? "" : "s"} ${removed.join(", ")}. ` +
654
+ "Nothing was written. " +
655
+ "To allow it, an operator must name the module in security.allowProtectedModuleUninstall for this site."
656
+ );
657
+ }
658
+ return { needsCurrentModules: false };
659
+ }
660
+
433
661
  /**
434
662
  * Whether a set of write attributes carries a publish action.
435
663
  *
@@ -723,23 +951,6 @@ export function redactCanonicalEntity(entity, secConfig, entityType) {
723
951
  return { ...entity, fields: redactedFields, ...baseOverrides };
724
952
  }
725
953
 
726
- /**
727
- * Redact a full JSON:API response by redacting each item under `.data`.
728
- * @param {?object} response JSON:API response with a `data` object or array.
729
- * @param {object} secConfig Resolved security config.
730
- * @param {string} entityType Entity type for the response data.
731
- * @returns {?object} New response; original is not mutated.
732
- */
733
- export function redactResponse(response, secConfig, entityType) {
734
- if (!response?.data) return response;
735
- return {
736
- ...response,
737
- data: Array.isArray(response.data)
738
- ? response.data.map((r) => redactResource(r, secConfig, entityType))
739
- : redactResource(response.data, secConfig, entityType),
740
- };
741
- }
742
-
743
954
  // ---------------------------------------------------------------------------
744
955
  // Security summary tool (exposed as drupal_security_info)
745
956
  // ---------------------------------------------------------------------------
@@ -768,5 +979,10 @@ export function getSecuritySummary(site) {
768
979
  entityRules: cfg.entityRules,
769
980
  globalRedactedFields: cfg.globalRedactedFields,
770
981
  declaredCeiling: cfg.declaredCeiling ?? null,
982
+ protectedModules: cfg.protectedModules,
983
+ protectedModuleOptOuts: cfg.protectedModuleOptOuts,
984
+ ...(cfg.protectedModulesError ? { protectedModulesError: cfg.protectedModulesError } : {}),
985
+ allowCoreExtensionChange: cfg.allowCoreExtensionChange,
986
+ ...(cfg.coreExtensionChangeError ? { coreExtensionChangeError: cfg.coreExtensionChangeError } : {}),
771
987
  };
772
988
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { entityRevisionId } from "./write-revision.js";
12
+ import { httpStatusOf } from "./error-status.js";
12
13
 
13
14
  const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
14
15
  const MISSING_DRAFT_ENDPOINT =
@@ -19,7 +20,6 @@ const MISSING_TRANSLATION_ENDPOINT =
19
20
  "Update MCP Sentinel; no canonical langcode PATCH was attempted.";
20
21
  const MISSING_DRAFT_RE = /does not provide Sentinel's governed draft endpoint/;
21
22
  const MISSING_TRANSLATION_RE = /does not provide Sentinel's governed draft-translation endpoint/;
22
- const DRUPAL_ABSENCE_RE = /Drupal (404|405)\b/;
23
23
 
24
24
  /**
25
25
  * Whether a backend can issue Sentinel's JSON:API draft/translation routes.
@@ -70,7 +70,8 @@ export function isMissingTranslationEndpoint(error) {
70
70
  * @returns {Error}
71
71
  */
72
72
  function missingEndpointError(error, message) {
73
- if (DRUPAL_ABSENCE_RE.test(String(error?.message))) {
73
+ const status = httpStatusOf(error);
74
+ if (status === 404 || status === 405) {
74
75
  return new Error(message, { cause: error });
75
76
  }
76
77
  return error instanceof Error ? error : new Error(String(error));