drupal-mcp-connector 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Drupal system-of-record adapter (#181).
3
+ *
4
+ * Wraps the connector's existing security, principal, and (injected) backend
5
+ * seams so the published contracts can be proven against Drupal without a
6
+ * second adapter and without rewriting MCP tool dispatch.
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import {
11
+ DEFAULT_SECURITY_PRESET,
12
+ SecurityError,
13
+ assertConfigWriteAllowed,
14
+ assertDeleteAllowed,
15
+ assertPublishAllowed,
16
+ assertReadAllowed,
17
+ assertWriteAllowed,
18
+ isPublishBearing,
19
+ resolveSecurityConfig,
20
+ } from "../security.js";
21
+ import { createMemoryApproval } from "./approval.js";
22
+ import { composeDecisions, ContractError, REASON } from "./decisions.js";
23
+ import { createMemoryEvidenceSink, requiresEvidence } from "./evidence-sink.js";
24
+ import { createMemoryBackend } from "./fixtures.js";
25
+ import { createLocalRelay, hintTargetName } from "./relay.js";
26
+ import { bindApprovalForExecute, comparePostconditions } from "./system-of-record.js";
27
+ import {
28
+ DECISION_RESULTS,
29
+ createActionManifest,
30
+ createDecisionRecord,
31
+ createExecutionReceipt,
32
+ createIdentityContext,
33
+ } from "./types.js";
34
+ import {
35
+ ADAPTER_CONTRACT_POLICY_REVISION,
36
+ ADAPTER_CONTRACT_VERSION,
37
+ negotiateContractVersion,
38
+ } from "./version.js";
39
+
40
+ const CONTROL_ENTITY_TYPES = new Set([
41
+ "user_role",
42
+ "oauth2_token",
43
+ "key",
44
+ "consumer",
45
+ "encryption_profile",
46
+ "mcp_tool_config",
47
+ "mcp_policy_profile",
48
+ ]);
49
+
50
+ const HOSTILE_HTML = /<\s*script\b|javascript\s*:|on(error|load|click)\s*=/i;
51
+
52
+ /**
53
+ * @param {object} [options]
54
+ * @returns {import("./system-of-record.js").SystemOfRecordAdapter}
55
+ */
56
+ export function createDrupalAdapter(options = {}) {
57
+ const sites = Array.isArray(options.sites)
58
+ ? options.sites
59
+ : (options.site ? [options.site] : []);
60
+ const defaultSite = options.site ?? sites[0];
61
+ const identity = options.identity
62
+ ? createIdentityContext(options.identity)
63
+ : null;
64
+ const backend = options.backend ?? createMemoryBackend();
65
+ const approval = options.approval ?? createMemoryApproval();
66
+ const evidence = options.evidence ?? createMemoryEvidenceSink();
67
+ const relay = options.relay ?? createLocalRelay({
68
+ sites,
69
+ grants: options.grants ?? null,
70
+ defaultSite: defaultSite?._name,
71
+ });
72
+ const upstreamEvaluator = options.upstreamEvaluator ?? null;
73
+ const assuranceClass = options.assuranceClass ?? "boundary_enforced";
74
+
75
+ return Object.freeze({
76
+ contractVersion: ADAPTER_CONTRACT_VERSION,
77
+ approval,
78
+ evidence,
79
+ backend,
80
+ relay,
81
+
82
+ /**
83
+ * @param {object} proposal
84
+ * @returns {string}
85
+ */
86
+ mapAction(proposal) {
87
+ return mapDrupalAction(proposal);
88
+ },
89
+
90
+ /**
91
+ * @param {object} proposal
92
+ * @returns {object}
93
+ */
94
+ propose(proposal) {
95
+ negotiateContractVersion(proposal?.contractVersion);
96
+ return createActionManifest({
97
+ ...proposal,
98
+ actionClass: mapDrupalAction(proposal),
99
+ contractVersion: ADAPTER_CONTRACT_VERSION,
100
+ target: proposal.target ?? {
101
+ name: hintTargetName(proposal.hints) ?? defaultSite?._name,
102
+ },
103
+ });
104
+ },
105
+
106
+ /**
107
+ * @param {object} manifest
108
+ * @returns {object}
109
+ */
110
+ evaluate(manifest) {
111
+ negotiateContractVersion(manifest.contractVersion);
112
+ const local = evaluateLocal(manifest, {
113
+ identity,
114
+ relay,
115
+ defaultSite,
116
+ sites,
117
+ });
118
+ const upstream = upstreamEvaluator
119
+ ? upstreamEvaluator.evaluate(manifest, identity)
120
+ : null;
121
+ return composeDecisions(upstream, local);
122
+ },
123
+
124
+ /**
125
+ * Execute only after a fresh evaluation. A caller allow cannot widen
126
+ * a local deny or skip required approval. `decision.decisionId` is
127
+ * kept on the receipt when present.
128
+ *
129
+ * @param {object} manifest
130
+ * @param {object} [decision]
131
+ * @param {{approvalId?: string}} [execOptions]
132
+ * @returns {Promise<object>}
133
+ */
134
+ async execute(manifest, decision = {}, execOptions = {}) {
135
+ if (decision.actionDigest && decision.actionDigest !== manifest.digest) {
136
+ return createExecutionReceipt({
137
+ decisionId: decision.decisionId,
138
+ outcome: "failed",
139
+ reason: REASON.REPLAY,
140
+ });
141
+ }
142
+
143
+ const local = this.evaluate(manifest);
144
+ const caller = typedCallerDecision(decision);
145
+ const authoritative = composeDecisions(caller, local);
146
+ const decisionId = decision.decisionId ?? authoritative.decisionId;
147
+
148
+ if (authoritative.result === "deny") {
149
+ return createExecutionReceipt({
150
+ decisionId,
151
+ outcome: "denied",
152
+ reason: authoritative.reason,
153
+ });
154
+ }
155
+
156
+ try {
157
+ bindApprovalForExecute(
158
+ authoritative,
159
+ manifest,
160
+ approval,
161
+ execOptions.approvalId,
162
+ identity?.subject,
163
+ );
164
+ } catch (err) {
165
+ return createExecutionReceipt({
166
+ decisionId,
167
+ outcome: "failed",
168
+ reason: err instanceof ContractError ? err.reason : REASON.APPROVAL_REQUIRED,
169
+ });
170
+ }
171
+
172
+ const needsEvidence = requiresEvidence(manifest.actionClass, assuranceClass);
173
+ if (needsEvidence) {
174
+ try {
175
+ evidence.writeRequired(createExecutionReceipt({
176
+ decisionId,
177
+ outcome: "pending",
178
+ }));
179
+ } catch (err) {
180
+ return createExecutionReceipt({
181
+ decisionId,
182
+ outcome: "failed",
183
+ reason: err instanceof ContractError ? err.reason : REASON.EVIDENCE_WRITE_FAILED,
184
+ });
185
+ }
186
+ }
187
+
188
+ const snapshot = typeof backend.captureState === "function"
189
+ ? await backend.captureState()
190
+ : null;
191
+
192
+ const written = await applyBackend(backend, manifest);
193
+ const observed = written
194
+ ? await backend.getEntity({
195
+ id: written.id,
196
+ entityType: manifest.entityType,
197
+ bundle: manifest.bundle,
198
+ }) ?? written
199
+ : null;
200
+ const declared = declaredEffects(manifest);
201
+ const post = comparePostconditions(declared, flattenObserved(observed));
202
+ const receipt = createExecutionReceipt({
203
+ decisionId,
204
+ outcome: post.ok ? "ok" : "unknown",
205
+ reason: post.ok ? undefined : post.reason,
206
+ nativeActor: identity?.subject ?? "local-operator",
207
+ revisionId: observed?.id,
208
+ after: observed,
209
+ declaredEffects: declared,
210
+ observed: flattenObserved(observed),
211
+ });
212
+
213
+ if (needsEvidence) {
214
+ try {
215
+ evidence.writeRequired(receipt);
216
+ } catch (err) {
217
+ await restoreBackend(backend, snapshot);
218
+ return createExecutionReceipt({
219
+ ...receipt,
220
+ receiptId: randomUUID(),
221
+ outcome: "failed",
222
+ reason: err instanceof ContractError ? err.reason : REASON.EVIDENCE_WRITE_FAILED,
223
+ });
224
+ }
225
+ } else {
226
+ evidence.writeAdvisory(receipt);
227
+ }
228
+
229
+ return receipt;
230
+ },
231
+ });
232
+ }
233
+
234
+ /**
235
+ * @param {object} proposal
236
+ * @returns {string}
237
+ */
238
+ export function mapDrupalAction(proposal = {}) {
239
+ const operation = String(proposal.operation ?? "read");
240
+ const entityType = String(proposal.entityType ?? "");
241
+ if (
242
+ operation === "config"
243
+ || operation === "config_set"
244
+ || operation === "config_get"
245
+ || CONTROL_ENTITY_TYPES.has(entityType)
246
+ ) {
247
+ return "control_plane";
248
+ }
249
+ if (operation === "delete" || operation === "publish" || isPublishBearing(proposal.attributes)) {
250
+ return "publish_or_destructive";
251
+ }
252
+ if (operation === "list" || operation === "export") {
253
+ return "exfiltration_read";
254
+ }
255
+ if (operation === "create" || operation === "update") {
256
+ return "reversible_write";
257
+ }
258
+ return "bounded_read";
259
+ }
260
+
261
+ /**
262
+ * @param {object} manifest
263
+ * @param {object} ctx
264
+ * @returns {object}
265
+ */
266
+ function evaluateLocal(manifest, ctx) {
267
+ const base = {
268
+ actionDigest: manifest.digest,
269
+ actionClass: manifest.actionClass,
270
+ policyDigest: policyDigestFor(ctx.defaultSite),
271
+ policyRevision: ADAPTER_CONTRACT_POLICY_REVISION,
272
+ evaluatorVersion: ADAPTER_CONTRACT_VERSION,
273
+ };
274
+
275
+ const hostile = detectHostileInput(manifest);
276
+ if (hostile) {
277
+ return deny(base, hostile, manifest.target);
278
+ }
279
+
280
+ let resolved;
281
+ try {
282
+ resolved = ctx.relay.resolve(ctx.identity, manifest.hints ?? {});
283
+ } catch (err) {
284
+ const reason = err instanceof ContractError ? err.reason : REASON.TENANT_ESCAPE;
285
+ return deny(base, reason, manifest.target);
286
+ }
287
+
288
+ const site = resolved.site;
289
+ const evaluated = {
290
+ ...base,
291
+ policyDigest: policyDigestFor(site),
292
+ };
293
+ const sec = resolveSecurityConfig(site);
294
+ const scope = requiredScope(manifest);
295
+ if (ctx.identity && scope && !ctx.identity.scopes.includes(scope)) {
296
+ return deny(evaluated, REASON.POLICY_DENIED, resolved, {
297
+ type: "scope",
298
+ scope,
299
+ });
300
+ }
301
+
302
+ try {
303
+ applySecurityGates(sec, manifest);
304
+ } catch (err) {
305
+ const reason = err instanceof SecurityError ? REASON.TARGET_DENIED : REASON.POLICY_DENIED;
306
+ return deny(evaluated, reason, resolved);
307
+ }
308
+
309
+ if (manifest.actionClass === "publish_or_destructive" || manifest.actionClass === "control_plane") {
310
+ const writeLike = manifest.operation !== "config_get" && manifest.operation !== "read";
311
+ if (writeLike) {
312
+ return createDecisionRecord({
313
+ ...evaluated,
314
+ result: "require_approval",
315
+ reason: REASON.APPROVAL_REQUIRED,
316
+ target: { name: resolved.name },
317
+ });
318
+ }
319
+ }
320
+
321
+ if (manifest.actionClass === "exfiltration_read") {
322
+ return createDecisionRecord({
323
+ ...evaluated,
324
+ result: "allow_with_obligations",
325
+ reason: "read_budget",
326
+ obligations: [{ type: "read_budget" }],
327
+ target: { name: resolved.name },
328
+ });
329
+ }
330
+
331
+ return createDecisionRecord({
332
+ ...evaluated,
333
+ result: "allow",
334
+ reason: "allow",
335
+ target: { name: resolved.name },
336
+ });
337
+ }
338
+
339
+ /**
340
+ * @param {object} sec
341
+ * @param {object} manifest
342
+ * @returns {void}
343
+ */
344
+ function applySecurityGates(sec, manifest) {
345
+ const entityType = manifest.entityType;
346
+ const bundle = manifest.bundle;
347
+ const operation = manifest.operation;
348
+
349
+ if (operation === "config" || operation === "config_set") {
350
+ assertConfigWriteAllowed(sec);
351
+ return;
352
+ }
353
+ if (operation === "config_get") {
354
+ return;
355
+ }
356
+ if (operation === "delete") {
357
+ assertDeleteAllowed(sec, entityType, bundle, manifest.id ?? "");
358
+ return;
359
+ }
360
+ if (operation === "create" || operation === "update" || operation === "publish") {
361
+ assertWriteAllowed(sec, operation === "publish" ? "update" : operation, entityType, bundle);
362
+ assertPublishAllowed(sec, manifest.attributes ?? {});
363
+ return;
364
+ }
365
+ if (operation === "list" || operation === "export" || operation === "read") {
366
+ if (entityType) assertReadAllowed(sec, entityType, bundle);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * @param {object} manifest
372
+ * @returns {string|null}
373
+ */
374
+ function detectHostileInput(manifest) {
375
+ const html = collectHtml(manifest);
376
+ if (html && HOSTILE_HTML.test(html)) return REASON.HOSTILE_INPUT;
377
+ if (manifest.filePath && isEscapingPath(manifest.filePath)) return REASON.HOSTILE_INPUT;
378
+ return null;
379
+ }
380
+
381
+ /**
382
+ * @param {object} manifest
383
+ * @returns {string}
384
+ */
385
+ function collectHtml(manifest) {
386
+ if (typeof manifest.html === "string") return manifest.html;
387
+ const body = manifest.attributes?.body;
388
+ if (typeof body === "string") return body;
389
+ if (body && typeof body === "object" && typeof body.value === "string") return body.value;
390
+ return "";
391
+ }
392
+
393
+ /**
394
+ * @param {string} filePath
395
+ * @returns {boolean}
396
+ */
397
+ function isEscapingPath(filePath) {
398
+ const normalized = String(filePath).split("\\").join("/");
399
+ if (normalized.includes("..")) return true;
400
+ if (normalized.includes("/.ssh/") || normalized.endsWith("/.ssh")) return true;
401
+ if (normalized.includes("/.env") || normalized.split("/").pop()?.startsWith(".env")) return true;
402
+ if (normalized.startsWith("/etc/") || normalized === "/etc/passwd") return true;
403
+ return false;
404
+ }
405
+
406
+ /**
407
+ * @param {object} manifest
408
+ * @returns {string|null}
409
+ */
410
+ function requiredScope(manifest) {
411
+ if (manifest.actionClass === "control_plane") return "mcp_config";
412
+ if (
413
+ manifest.actionClass === "reversible_write"
414
+ || manifest.actionClass === "publish_or_destructive"
415
+ ) {
416
+ return "mcp_write";
417
+ }
418
+ return "mcp_read";
419
+ }
420
+
421
+ /**
422
+ * @param {object} site
423
+ * @returns {string}
424
+ */
425
+ function policyDigestFor(site) {
426
+ const preset = site?.security?.preset ?? DEFAULT_SECURITY_PRESET;
427
+ return `${preset}:${ADAPTER_CONTRACT_POLICY_REVISION}`;
428
+ }
429
+
430
+ /**
431
+ * @param {object} base
432
+ * @param {string} reason
433
+ * @param {object} [target]
434
+ * @param {object} [challenge]
435
+ * @returns {object}
436
+ */
437
+ function deny(base, reason, target, challenge) {
438
+ return createDecisionRecord({
439
+ ...base,
440
+ result: "deny",
441
+ reason,
442
+ reasons: [reason],
443
+ target: target?.name ? { name: target.name } : target,
444
+ challenge,
445
+ });
446
+ }
447
+
448
+ /**
449
+ * @param {object} [decision]
450
+ * @returns {object|null}
451
+ */
452
+ function typedCallerDecision(decision) {
453
+ if (!decision || typeof decision !== "object") return null;
454
+ if (!DECISION_RESULTS.includes(decision.result)) return null;
455
+ return decision;
456
+ }
457
+
458
+ /**
459
+ * @param {object} backend
460
+ * @param {*} snapshot
461
+ * @returns {Promise<void>}
462
+ */
463
+ async function restoreBackend(backend, snapshot) {
464
+ if (snapshot === undefined || snapshot === null || typeof backend.restoreState !== "function") {
465
+ return;
466
+ }
467
+ await backend.restoreState(snapshot);
468
+ }
469
+
470
+ /**
471
+ * @param {object} backend
472
+ * @param {object} manifest
473
+ * @returns {Promise<object|null>}
474
+ */
475
+ async function applyBackend(backend, manifest) {
476
+ const ref = {
477
+ entityType: manifest.entityType,
478
+ bundle: manifest.bundle,
479
+ id: manifest.id,
480
+ attributes: { ...(manifest.attributes ?? {}) },
481
+ };
482
+ if (manifest.operation === "delete") {
483
+ await backend.deleteEntity(ref);
484
+ return { id: manifest.id, deleted: true };
485
+ }
486
+ if (manifest.operation === "create" || manifest.operation === "publish") {
487
+ if (manifest.operation === "publish") ref.attributes.status = true;
488
+ if (manifest.operation === "publish" && manifest.id) {
489
+ return backend.updateEntity(ref);
490
+ }
491
+ return backend.createEntity(ref);
492
+ }
493
+ if (manifest.operation === "update") {
494
+ return backend.updateEntity(ref);
495
+ }
496
+ if (manifest.id) {
497
+ return backend.getEntity(ref);
498
+ }
499
+ return null;
500
+ }
501
+
502
+ /**
503
+ * @param {object} manifest
504
+ * @returns {object|undefined}
505
+ */
506
+ function declaredEffects(manifest) {
507
+ if (manifest.expectedEffects) return { ...manifest.expectedEffects };
508
+ if (manifest.attributes && manifest.attributes.status !== undefined) {
509
+ return { status: manifest.attributes.status };
510
+ }
511
+ return undefined;
512
+ }
513
+
514
+ /**
515
+ * @param {object|null} entity
516
+ * @returns {object|undefined}
517
+ */
518
+ function flattenObserved(entity) {
519
+ if (!entity) return undefined;
520
+ return {
521
+ id: entity.id,
522
+ status: entity.status ?? entity.attributes?.status,
523
+ deleted: entity.deleted,
524
+ };
525
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Policy-evaluator contract (#181).
3
+ *
4
+ * An evaluator returns a typed DecisionRecord. It does not execute. Callers
5
+ * compose upstream and local decisions with composeDecisions — never by
6
+ * trusting an upstream allow as authority.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} PolicyEvaluator
11
+ * @property {(manifest: object, identity: object) => object} evaluate
12
+ */
13
+
14
+ /**
15
+ * Wrap a function as a PolicyEvaluator.
16
+ * @param {(manifest: object, identity: object) => object} evaluateFn
17
+ * @returns {PolicyEvaluator}
18
+ */
19
+ export function createEvaluator(evaluateFn) {
20
+ if (typeof evaluateFn !== "function") {
21
+ throw new TypeError("createEvaluator requires an evaluate function");
22
+ }
23
+ return Object.freeze({
24
+ evaluate(manifest, identity) {
25
+ return evaluateFn(manifest, identity);
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Evidence-sink contract (#181).
3
+ *
4
+ * When policy or assurance class requires durable evidence, failure to write
5
+ * it fails the governed action. Advisory writes may degrade.
6
+ */
7
+
8
+ import { ContractError, REASON } from "./decisions.js";
9
+
10
+ /**
11
+ * @typedef {Object} EvidenceSink
12
+ * @property {(receipt: object) => void} writeRequired
13
+ * @property {(receipt: object) => {ok: boolean, degraded?: boolean}} writeAdvisory
14
+ */
15
+
16
+ /**
17
+ * In-process evidence sink. `failRequired` makes every required write fail.
18
+ * `failFinalRequired` fails only the post-mutation receipt (outcome other
19
+ * than `pending`).
20
+ *
21
+ * @param {{failRequired?: boolean, failFinalRequired?: boolean}} [options]
22
+ * @returns {EvidenceSink & {records: object[]}}
23
+ */
24
+ export function createMemoryEvidenceSink({
25
+ failRequired = false,
26
+ failFinalRequired = false,
27
+ } = {}) {
28
+ const records = [];
29
+
30
+ return Object.freeze({
31
+ records,
32
+ /**
33
+ * @param {object} receipt
34
+ * @returns {void}
35
+ */
36
+ writeRequired(receipt) {
37
+ if (failRequired || (failFinalRequired && receipt?.outcome !== "pending")) {
38
+ throw new ContractError(
39
+ "Required evidence write failed.",
40
+ REASON.EVIDENCE_WRITE_FAILED,
41
+ );
42
+ }
43
+ records.push({ required: true, receipt });
44
+ },
45
+
46
+ /**
47
+ * @param {object} receipt
48
+ * @returns {{ok: boolean}}
49
+ */
50
+ writeAdvisory(receipt) {
51
+ records.push({ required: false, receipt });
52
+ return { ok: true };
53
+ },
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Whether this action class must persist evidence at the given assurance.
59
+ *
60
+ * @param {string} actionClass
61
+ * @param {string} assuranceClass
62
+ * @returns {boolean}
63
+ */
64
+ export function requiresEvidence(actionClass, assuranceClass) {
65
+ if (assuranceClass === "advisory") return false;
66
+ return actionClass === "publish_or_destructive" || actionClass === "control_plane";
67
+ }