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.
- package/.agents/commands/drupal-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +254 -0
- package/README.md +10 -3
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/dispatch.js +6 -6
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/errors.js +0 -11
- package/src/lib/evidence.js +0 -6
- package/src/lib/governance.js +2 -8
- package/src/lib/link-checker.js +3 -3
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/operations.js +2 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/policy-enforcement.js +4 -4
- package/src/lib/principal.js +3 -3
- package/src/lib/relay/edge.js +2 -2
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +234 -18
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +139 -8
- package/src/lib/usage.js +0 -9
- package/src/lib/verify.js +164 -61
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/nodes.js +16 -6
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- package/src/tools/translations.js +5 -1
- package/src/lib/draft-write.js +0 -19
- 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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
|
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
|
|
736
|
-
* means the write was accepted, which for an
|
|
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
|
-
|
|
744
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/tools/config.js
CHANGED
|
@@ -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 {
|
|
30
|
+
import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
|
|
26
31
|
|
|
27
|
-
/**
|
|
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
|
|
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
|
|
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"],
|