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
package/src/lib/verify.js CHANGED
@@ -20,6 +20,8 @@
20
20
  import { createHash } from "node:crypto";
21
21
  import { CLIENT_VERSION } from "./config.js";
22
22
  import { resolveInboundAuthConfig, resolveInboundAuthMode } from "./http-auth.js";
23
+ import { httpStatusOf } from "./error-status.js";
24
+ import { advertisedServerToolNames, serverToolCandidates, SERVER_TOOL_IDS } from "./server-tools.js";
23
25
 
24
26
  /** Check outcome vocabulary.
25
27
  *
@@ -30,9 +32,9 @@ import { resolveInboundAuthConfig, resolveInboundAuthMode } from "./http-auth.js
30
32
  * and does not fail it: a verifier a secure install can never pass is a
31
33
  * verifier people stop running.
32
34
  */
33
- export const PASS = "pass";
34
- export const FAIL = "fail";
35
- export const SKIPPED = "skipped";
35
+ const PASS = "pass";
36
+ const FAIL = "fail";
37
+ const SKIPPED = "skipped";
36
38
  export const NOT_APPLICABLE = "n/a";
37
39
 
38
40
  /** Every static check, in report order. */
@@ -473,9 +475,6 @@ export const LIVE_CHECKS = [
473
475
  /** A page size no governed profile should ever serve in one response. */
474
476
  const MASS_READ_LIMIT = 5000;
475
477
 
476
- /** The governed config-write tool, under the bridge's derivative name. */
477
- const CONFIG_SET_TOOL = "tool_api.mcp_sentinel_config_set";
478
-
479
478
  /** Joins a base URL and a path without doubling or dropping the separator. */
480
479
  function joinUrl(baseUrl, path) {
481
480
  return `${String(baseUrl).replace(/\/+$/, "")}/${String(path).replace(/^\/+/, "")}`;
@@ -518,6 +517,41 @@ async function attempt(transport, url, init = {}) {
518
517
  }
519
518
  }
520
519
 
520
+ /** Standard JSON-RPC codes: the call itself was wrong, so nothing decided. */
521
+ const STANDARD_RPC_CODES = new Set([-32700, -32600, -32601, -32602, -32603]);
522
+
523
+ // The bridge client's documented message shapes, anchored at the start. A tool
524
+ // name holds no whitespace, so text after the prefix can never change which
525
+ // shape a message is.
526
+ const SESSION_FAILURE_RE = /^Server-tool session initialize /;
527
+ const HTTP_FAILURE_RE = /^Server-tool call \S+ failed \d{3}(?!\w)/;
528
+ const TOOL_ERROR_RE = /^Server-tool \S+ reported an error:/;
529
+ const RPC_ERROR_RE = /^Server-tool \S+ error \((-?\d+)\):/;
530
+
531
+ /**
532
+ * What failed in a bridge call, read from the error the bridge client threw.
533
+ *
534
+ * The client marks its errors (`bridgeFailure`, plus `status` or `rpcCode`), and
535
+ * those properties decide. An error with no marker is read from the start of a
536
+ * documented message only. The rest of a message is a response body or tool
537
+ * text: a status, a JSON-RPC code or the words "reported an error" found there
538
+ * describe nothing about this call (#361).
539
+ * @param {unknown} error Thrown value.
540
+ * @param {string} message Its message.
541
+ * @returns {{kind: "session"|"http"|"tool"|"rpc"|null, status?: ?number, code?: ?number}}
542
+ */
543
+ function bridgeFailureOf(error, message) {
544
+ const marker = typeof error?.bridgeFailure === "string" ? error.bridgeFailure : null;
545
+ if (marker === "session" || (!marker && SESSION_FAILURE_RE.test(message))) return { kind: "session" };
546
+ if (marker === "http" || (!marker && HTTP_FAILURE_RE.test(message))) {
547
+ return { kind: "http", status: httpStatusOf(error) };
548
+ }
549
+ if (marker === "tool" || (!marker && TOOL_ERROR_RE.test(message))) return { kind: "tool" };
550
+ if (marker === "rpc") return { kind: "rpc", code: Number.isInteger(error.rpcCode) ? error.rpcCode : null };
551
+ const rpc = marker ? null : RPC_ERROR_RE.exec(message);
552
+ return rpc ? { kind: "rpc", code: Number(rpc[1]) } : { kind: null };
553
+ }
554
+
521
555
  /**
522
556
  * Whether a thrown bridge error is a POLICY refusal or an unexercised probe.
523
557
  *
@@ -526,11 +560,17 @@ async function attempt(transport, url, init = {}) {
526
560
  * them mean the source decided:
527
561
  *
528
562
  * - a tool-level error (the tool ran and refused) → refused
529
- * - a server-defined JSON-RPC error (-32000..-32099) → refused
563
+ * - a server-defined JSON-RPC error (not a standard code) → refused
530
564
  * - an HTTP 401/403 on the tools/call → refused
531
565
  * - a standard JSON-RPC error (method not found, bad params) → unexercised
566
+ * - a JSON-RPC error with no readable code → unexercised
532
567
  * - any other HTTP status (400, 404, 5xx) → unexercised
533
- * - no bridge configured, session init, network failure → unexercised
568
+ * - no bridge configured, session init, token, network → unexercised
569
+ *
570
+ * The HTTP status comes from the error's `status` property (`httpStatusOf`),
571
+ * and only for a failure of the tools/call itself: the token endpoint and the
572
+ * session handshake answer 401 and 403 too, before any tool is reached. A
573
+ * status is never read from the response body.
534
574
  *
535
575
  * Scoring an unexercised probe as a refusal is how a verifier produces a green
536
576
  * document for an install that never proved anything.
@@ -541,32 +581,36 @@ async function attempt(transport, url, init = {}) {
541
581
  export function classifyBridgeError(error) {
542
582
  const message = String(error?.message ?? error);
543
583
  const detail = message.slice(0, 200);
584
+ const failure = bridgeFailureOf(error, message);
585
+ const refused =
586
+ failure.kind === "tool" ||
587
+ (failure.kind === "http" && (failure.status === 401 || failure.status === 403)) ||
588
+ (failure.kind === "rpc" && failure.code !== null && !STANDARD_RPC_CODES.has(failure.code));
589
+ return { outcome: refused ? "refused" : "unexercised", detail };
590
+ }
544
591
 
545
- // The tool ran and reported an error: the canonical governed refusal.
546
- if (/ reported an error:/.test(message)) return { outcome: "refused", detail };
547
-
548
- // JSON-RPC error at the tools/call. Server-defined codes are decisions;
549
- // the standard codes mean the call itself was wrong.
550
- const rpc = message.match(/ error \((-?\d+)\):/);
551
- if (rpc) {
552
- const code = Number(rpc[1]);
553
- const isStandard = [-32700, -32600, -32601, -32602, -32603].includes(code);
554
- // A session-initialize error never reached the tool, whatever its code.
555
- if (/session initialize/.test(message)) return { outcome: "unexercised", detail };
556
- return isStandard ? { outcome: "unexercised", detail } : { outcome: "refused", detail };
557
- }
558
-
559
- // HTTP status on the tools/call: an authorisation status is a decision.
560
- const http = message.match(/^Server-tool call .* failed (\d{3}):/);
561
- if (http) {
562
- const status = Number(http[1]);
563
- return status === 401 || status === 403
564
- ? { outcome: "refused", detail }
565
- : { outcome: "unexercised", detail };
566
- }
592
+ /**
593
+ * Whether a status is a 4xx: the target answered and declined the request.
594
+ * @param {?number} status HTTP status, or null when there was no answer.
595
+ * @returns {boolean}
596
+ */
597
+ function isClientErrorStatus(status) {
598
+ return Number.isInteger(status) && status >= 400 && status <= 499;
599
+ }
567
600
 
568
- // Bridge not configured, session initialise failure, transport error.
569
- return { outcome: "unexercised", detail };
601
+ /**
602
+ * Whether a failed mass read is a refusal by the source.
603
+ *
604
+ * 401, 403 and 429 are decisions. Any other 4xx counts only when the body
605
+ * carries the source's own refusal code (e.g. `read_budget_exceeded`). A 404
606
+ * means the collection does not exist, and a 5xx or no answer means the request
607
+ * failed; none of those reached the read control.
608
+ * @param {{status: ?number, codes: string[]}} result Reduced transport attempt.
609
+ * @returns {boolean}
610
+ */
611
+ function isMassReadRefusal({ status, codes }) {
612
+ if (status === 401 || status === 403 || status === 429) return true;
613
+ return isClientErrorStatus(status) && status !== 404 && codes.length > 0;
570
614
  }
571
615
 
572
616
  /** Builds a live check result, carrying what was observed. */
@@ -593,11 +637,13 @@ function liveCheck(id, title, findings, observed = null, { skipped = false, notA
593
637
  * non-production environment first.
594
638
  *
595
639
  * @param {object} site Resolved site config (with oauth.clientSecret resolved).
596
- * @param {{transport: Function, now?: () => Date}} deps
640
+ * @param {{transport: Function, callTool?: Function, listTools?: Function, now?: () => Date}} deps
597
641
  * `transport` is fetch-shaped and injectable so this is testable offline.
642
+ * `callTool` and `listTools` are the bridge client's `tools/call` and
643
+ * `tools/list`; without `listTools` the config probe cannot pass.
598
644
  * @returns {Promise<object>} Evidence document. Never contains secrets or payloads.
599
645
  */
600
- export async function verifyLive(site, { transport, callTool = null, contentTarget = null, contentTargetType = null, now = () => new Date() }) {
646
+ export async function verifyLive(site, { transport, callTool = null, listTools = null, contentTarget = null, contentTargetType = null, now = () => new Date() }) {
601
647
  const baseUrl = String(site?.baseUrl ?? "");
602
648
  const checks = [];
603
649
  const httpsOk = baseUrl.startsWith("https://") || isLoopback(baseUrl);
@@ -663,24 +709,31 @@ export async function verifyLive(site, { transport, callTool = null, contentTarg
663
709
  tokenError = String(err?.message ?? err);
664
710
  }
665
711
  const anonymous = await attempt(transport, joinUrl(baseUrl, "/drupal-mcp/readiness"));
712
+ const authFindings = [];
713
+ if (!token) {
714
+ authFindings.push(
715
+ `the principal did not obtain a usable access token (status ${tokenStatus ?? "none"}` +
716
+ `${tokenError ? `, ${tokenError}` : ""}).`,
717
+ );
718
+ }
719
+ if (anonymous.ok) {
720
+ authFindings.push("a governed path answered an anonymous request; authentication is not being enforced.");
721
+ }
722
+ // A server failure or no answer is not a refusal: the anonymous request
723
+ // never reached an access decision, so the claim is not proven either way.
724
+ const anonymousUndecided = !anonymous.ok && !isClientErrorStatus(anonymous.status);
666
725
  checks.push(
667
726
  liveCheck(
668
727
  "principal_auth",
669
728
  "The principal authenticates, and anonymous access is refused",
670
- (() => {
671
- const findings = [];
672
- if (!token) {
673
- findings.push(
674
- `the principal did not obtain a usable access token (status ${tokenStatus ?? "none"}` +
675
- `${tokenError ? `, ${tokenError}` : ""}).`,
676
- );
677
- }
678
- if (anonymous.ok) {
679
- findings.push("a governed path answered an anonymous request; authentication is not being enforced.");
680
- }
681
- return findings;
682
- })(),
729
+ authFindings,
683
730
  { tokenStatus, anonymousStatus: anonymous.status },
731
+ authFindings.length === 0 && anonymousUndecided
732
+ ? {
733
+ skipped: true,
734
+ skipReason: `the anonymous request was not refused, it failed (status ${anonymous.status ?? "none"}), which proves nothing about whether anonymous access is refused.`,
735
+ }
736
+ : {},
684
737
  ),
685
738
  );
686
739
  }
@@ -724,7 +777,7 @@ export async function verifyLive(site, { transport, callTool = null, contentTarg
724
777
 
725
778
  // --- entitlement filtering ----------------------------------------------
726
779
  // Through the connector's own bridge client, so the probe exercises the real
727
- // contract: an MCP session, the governed tool's `tool_api.*` name and its
780
+ // contract: an MCP session, the governed tool's advertised wire name and its
728
781
  // argument shape, and a refusal surfaced as a tool/JSON-RPC error rather
729
782
  // than an HTTP status. A hand-rolled JSON-RPC body would "pass" for the
730
783
  // wrong reason — the server would reject it as malformed, not as denied.
@@ -732,25 +785,67 @@ export async function verifyLive(site, { transport, callTool = null, contentTarg
732
785
  const holdsConfigScope = scopes.includes("mcp_config");
733
786
 
734
787
  /**
735
- * Attempts a governed config write. Returns {served, detail}: `served` true
736
- * means the write was accepted, which for an out-of-tier principal is the
737
- * finding.
788
+ * Attempts a governed config write. Returns {served, outcome, detail,
789
+ * advertised, tool}: `served` true means the write was accepted, which for an
790
+ * out-of-tier principal is the finding.
791
+ *
792
+ * The source catalog is read first. A call to a name the source does not
793
+ * publish fails too, and that failure is indistinguishable from a refusal, so
794
+ * a refusal only counts for a tool the catalog lists. An unadvertised name is
795
+ * still attempted, because a served write is a finding either way; anything
796
+ * short of served is then `unexercised`, never a pass.
738
797
  */
739
798
  const attemptConfigWrite = async () => {
799
+ let candidates;
740
800
  try {
741
801
  // The negative probe must reach source authorization, not merely prove
742
802
  // that the connector hides out-of-tier tools from discovery.
743
- const tool = site.serverTools?.bindings === undefined ? CONFIG_SET_TOOL
744
- : (await import("./module-tools.js")).resolveModuleBinding(site, "configSet", {
803
+ candidates = site.serverTools?.bindings === undefined
804
+ ? serverToolCandidates(SERVER_TOOL_IDS.configSet)
805
+ : [(await import("./module-tools.js")).resolveModuleBinding(site, "configSet", {
745
806
  operation: "write", scope: "mcp_config", capabilities: ["configWrite"],
746
- }).policy.name;
747
- await callTool(site, tool, { name: "system.site", data: { name: "verification probe" } });
748
- return { served: true, outcome: "served", detail: "accepted" };
807
+ }).policy.name];
749
808
  } catch (err) {
750
- // Not every throw is a refusal — see classifyBridgeError.
751
809
  const { outcome, detail } = classifyBridgeError(err);
752
- return { served: false, outcome, detail };
810
+ return { served: false, outcome, detail, advertised: false, tool: null };
753
811
  }
812
+
813
+ let names = null;
814
+ let catalogProblem = null;
815
+ if (typeof listTools !== "function") {
816
+ catalogProblem = "the source catalog could not be read: no tools/list reader was supplied.";
817
+ } else {
818
+ try {
819
+ names = await advertisedServerToolNames(site, listTools);
820
+ } catch (err) {
821
+ catalogProblem = `the source catalog could not be read: ${String(err?.message ?? err).slice(0, 200)}`;
822
+ }
823
+ }
824
+ const tool = names ? candidates.find((name) => names.has(name)) ?? null : null;
825
+ const advertised = tool !== null;
826
+
827
+ for (const name of advertised ? [tool] : candidates) {
828
+ try {
829
+ await callTool(site, name, { name: "system.site", data: { name: "verification probe" } });
830
+ return { served: true, outcome: "served", detail: "accepted", advertised, tool: name };
831
+ } catch (err) {
832
+ if (advertised) {
833
+ // Not every throw is a refusal — see classifyBridgeError.
834
+ const { outcome, detail } = classifyBridgeError(err);
835
+ return { served: false, outcome, detail, advertised, tool: name };
836
+ }
837
+ }
838
+ }
839
+ return {
840
+ served: false,
841
+ outcome: "unexercised",
842
+ advertised: false,
843
+ tool: null,
844
+ detail: catalogProblem ??
845
+ `the config write tool is not advertised by the source to this principal (looked for ${candidates.join(" and ")} in tools/list), ` +
846
+ "so a failed call cannot be told apart from a refusal. Check that the tool is registered and enabled; " +
847
+ "a source that filters discovery by entitlement hides it from this principal, so confirm the name with a principal that holds mcp_config.",
848
+ };
754
849
  };
755
850
 
756
851
  let configWrite = null;
@@ -776,7 +871,7 @@ export async function verifyLive(site, { transport, callTool = null, contentTarg
776
871
  configWrite.outcome === "unexercised"
777
872
  ? liveCheck("entitlement_filtering", "Out-of-tier operations are filtered for this principal", [], configWrite, {
778
873
  skipped: true,
779
- skipReason: `the governed tool call never ran, so no policy decision was observed: ${configWrite.detail}`,
874
+ skipReason: `no policy decision on the governed tool call was observed, so nothing was proven: ${configWrite.detail}`,
780
875
  })
781
876
  : liveCheck(
782
877
  "entitlement_filtering",
@@ -823,8 +918,16 @@ export async function verifyLive(site, { transport, callTool = null, contentTarg
823
918
  (() => {
824
919
  const observed = { status: massRead.status, codes: massRead.codes, items: massRead.count };
825
920
  const title = `A ${MASS_READ_LIMIT}-item read is refused or bounded`;
826
- // Refused outright: the control fired.
827
- if (!massRead.ok) return liveCheck("probe_mass_read", title, [], observed);
921
+ // Refused outright: the control fired. Only a status the source chose
922
+ // counts. A 5xx, a 404 or no answer never reached the read control.
923
+ if (!massRead.ok) {
924
+ return isMassReadRefusal(massRead)
925
+ ? liveCheck("probe_mass_read", title, [], observed)
926
+ : liveCheck("probe_mass_read", title, [], observed, {
927
+ skipped: true,
928
+ skipReason: `the read failed (status ${massRead.status ?? "none"}), which is not a refusal and proves nothing about the read bound.`,
929
+ });
930
+ }
828
931
  // Served, but bounded well below what was asked for: also the control
829
932
  // firing — a cap is a bound, and reporting it as unbounded would train
830
933
  // operators to ignore the verifier.
@@ -20,18 +20,27 @@ import {
20
20
  assertConfigReadAllowed,
21
21
  assertConfigWriteAllowed,
22
22
  assertConfigScope,
23
+ assertCoreExtensionChangeAllowed,
24
+ assertCoreExtensionValueKeepsProtected,
25
+ isCoreExtensionConfig,
23
26
  hasScope,
27
+ CORE_EXTENSION_CONFIG,
28
+ SecurityError,
24
29
  } from "../lib/security.js";
25
- import { callServerTool, callBoundModuleTool, SERVER_TOOLS } from "../lib/server-tools.js";
30
+ import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
26
31
 
27
- /** Compatibility names use an approved module binding when configured. */
32
+ /**
33
+ * Compatibility names use an approved module binding when configured. Without
34
+ * bindings, the wire name comes from the source's own tools/list; a tool the
35
+ * source does not advertise fails closed before any call.
36
+ */
28
37
  async function configTool(site, binding, args, operation, capability) {
29
38
  if (site.serverTools?.bindings !== undefined) {
30
39
  return callBoundModuleTool(site, binding, args, {
31
40
  operation, scope: "mcp_config", capabilities: [capability],
32
41
  });
33
42
  }
34
- return callServerTool(site, new Map(Object.entries(SERVER_TOOLS)).get(binding), args);
43
+ return callGovernedServerTool(site, binding, args);
35
44
  }
36
45
 
37
46
  // ---------------------------------------------------------------------------
@@ -65,6 +74,56 @@ async function configList({ site: siteName, prefix }) {
65
74
  return configTool(site, "configList", args, "read", "configRead");
66
75
  }
67
76
 
77
+ /**
78
+ * Read the machine names of the modules installed now, through the governed
79
+ * config read. Used only to check a core.extension write (#349).
80
+ * @param {object} site Resolved site config.
81
+ * @param {object} sec Resolved security config.
82
+ * @returns {Promise<string[]>} Installed module machine names.
83
+ * @throws {SecurityError} if the list cannot be read or understood. The caller
84
+ * then writes nothing.
85
+ */
86
+ async function readInstalledModules(site, sec) {
87
+ const blocked = "The write to core.extension is refused because the current module list could not be read, " +
88
+ "so the connector cannot tell whether a protected module would be removed. Nothing was written.";
89
+ let data;
90
+ try {
91
+ assertConfigReadAllowed(sec);
92
+ data = toolResultData(await configTool(site, "configGet", { name: CORE_EXTENSION_CONFIG }, "read", "configRead"));
93
+ } catch (err) {
94
+ throw new SecurityError(`${blocked} Reason: ${String(err?.message ?? err).slice(0, 300)}`);
95
+ }
96
+ // The source's config tool answers { name, data }; a plain config map is read too.
97
+ const modules = [data?.data?.module, data?.module]
98
+ .find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate));
99
+ if (!modules) throw new SecurityError(`${blocked} Reason: the read returned no module map.`);
100
+ return Object.keys(modules);
101
+ }
102
+
103
+ /**
104
+ * Gate a drupal_config_set on core.extension (#349). A write there can install
105
+ * or uninstall any module or theme without Drupal's install and uninstall
106
+ * steps, and it bypasses the protected-module list. It is refused unless the
107
+ * operator opted in; with the opt-in, a value that would remove or alter a
108
+ * protected module is still refused.
109
+ * @param {object} site Resolved site config.
110
+ * @param {object} sec Resolved security config.
111
+ * @param {object} value The submitted map of config keys to values.
112
+ * @returns {Promise<void>}
113
+ * @throws {SecurityError} if the write is refused.
114
+ */
115
+ async function assertCoreExtensionWriteAllowed(site, sec, value) {
116
+ assertCoreExtensionChangeAllowed(
117
+ sec,
118
+ "drupal_config_set does not write core.extension. A write there can install or uninstall any module or theme " +
119
+ "without running Drupal's install and uninstall steps, and it bypasses the protected-module list. Nothing was written."
120
+ );
121
+ const { needsCurrentModules } = assertCoreExtensionValueKeepsProtected(sec, value);
122
+ if (needsCurrentModules) {
123
+ assertCoreExtensionValueKeepsProtected(sec, value, await readInstalledModules(site, sec));
124
+ }
125
+ }
126
+
68
127
  /**
69
128
  * Set a configuration value. Governed and audited server-side; the connector
70
129
  * additionally enforces the config-write cap before dispatching.
@@ -73,9 +132,14 @@ async function configList({ site: siteName, prefix }) {
73
132
  * server-side tool (mcp_sentinel McpConfigSetTool) takes that map under the key
74
133
  * `data` and applies a partial `$editable->set($key, $value)` per entry, so we
75
134
  * translate `value` → `data` at the call site.
135
+ *
136
+ * A write to `core.extension` is refused unless the operator set
137
+ * `security.allowCoreExtensionChange` (#349). The check runs here, before the
138
+ * binding path and the unbound path split.
76
139
  * @param {object} args - { site?, name, value }.
77
140
  * @returns {Promise<*>} The server tool's result.
78
- * @throws {SecurityError} if the site is read-only or config writes are disabled.
141
+ * @throws {SecurityError} if the site is read-only, config writes are disabled,
142
+ * or the write targets core.extension without the operator's opt-in.
79
143
  */
80
144
  async function configSet({ site: siteName, name, value }) {
81
145
  const site = getSiteConfig(siteName);
@@ -83,6 +147,7 @@ async function configSet({ site: siteName, name, value }) {
83
147
  assertConfigScope(site, `config:set ${name}`);
84
148
  assertNotReadOnly(sec, `config:set ${name}`);
85
149
  assertConfigWriteAllowed(sec);
150
+ if (isCoreExtensionConfig(name)) await assertCoreExtensionWriteAllowed(site, sec, value);
86
151
  return configTool(site, "configSet", { name, data: value }, "write", "configWrite");
87
152
  }
88
153
 
@@ -181,7 +246,7 @@ export const definitions = [
181
246
  },
182
247
  {
183
248
  name: "drupal_config_set",
184
- description: "Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR.",
249
+ description: "Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR. Refused for `core.extension`, which lists installed modules and themes: use drupal_drush_module_enable or drupal_drush_module_disable instead. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info), and a value that removes a protected module is still refused.",
185
250
  inputSchema: {
186
251
  type: "object",
187
252
  required: ["name", "value"],