drupal-mcp-connector 2.7.4 → 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.
Files changed (152) hide show
  1. package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
  3. package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
  6. package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
  7. package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
  8. package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
  9. package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
  10. package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
  11. package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
  13. package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
  16. package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
  18. package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
  19. package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
  20. package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
  21. package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
  23. package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
  24. package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
  25. package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
  26. package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
  27. package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
  28. package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
  29. package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
  30. package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
  31. package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
  33. package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
  34. package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
  35. package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
  36. package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
  37. package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
  39. package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
  40. package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
  43. package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
  44. package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
  45. package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
  46. package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
  49. package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
  50. package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
  51. package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
  52. package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
  54. package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
  55. package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
  57. package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
  58. package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
  59. package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
  60. package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
  62. package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
  65. package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
  68. package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
  69. package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
  70. package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
  71. package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
  72. package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
  73. package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
  74. package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
  76. package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
  77. package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
  78. package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
  79. package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
  80. package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
  81. package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
  82. package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
  84. package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
  85. package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
  87. package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
  88. package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
  89. package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
  90. package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
  91. package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
  92. package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
  93. package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
  95. package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
  96. package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
  97. package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
  98. package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
  99. package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
  100. package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
  101. package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
  102. package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
  103. package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
  107. package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
  108. package/{.claude → .agents}/commands/drupal-search.md +3 -4
  109. package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
  110. package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
  111. package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
  112. package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
  114. package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
  117. package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
  118. package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
  121. package/CHANGELOG.md +82 -0
  122. package/README.md +23 -12
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/contracts/approval.js +72 -0
  131. package/src/lib/contracts/decisions.js +139 -0
  132. package/src/lib/contracts/drupal.js +525 -0
  133. package/src/lib/contracts/evaluator.js +28 -0
  134. package/src/lib/contracts/evidence-sink.js +67 -0
  135. package/src/lib/contracts/fixtures.js +117 -0
  136. package/src/lib/contracts/index.js +46 -0
  137. package/src/lib/contracts/relay.js +83 -0
  138. package/src/lib/contracts/system-of-record.js +61 -0
  139. package/src/lib/contracts/types.js +242 -0
  140. package/src/lib/contracts/version.js +52 -0
  141. package/src/lib/data-flow.js +437 -0
  142. package/src/lib/dispatch.js +36 -15
  143. package/src/lib/drupal-fetch.js +52 -6
  144. package/src/lib/field-definition.js +285 -0
  145. package/src/lib/patch-preflight.js +157 -66
  146. package/src/lib/security.js +14 -0
  147. package/src/lib/server-tools.js +9 -0
  148. package/src/lib/write-revision.js +82 -2
  149. package/src/tools/bulk.js +4 -3
  150. package/src/tools/entities.js +11 -7
  151. package/src/tools/index.js +3 -2
  152. package/src/tools/nodes.js +55 -43
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Typed decisions, stable reason codes, and the narrowing compose rule (#181).
3
+ *
4
+ * An upstream allow cannot widen a local or target deny. Obligations union
5
+ * only when both sides allow. Model and agent vendor keys are not part of
6
+ * any contract record.
7
+ */
8
+
9
+ /** Stable contract-level reason codes. Do not invent a parallel set. */
10
+ export const REASON = Object.freeze({
11
+ POLICY_DENIED: "policy_denied",
12
+ TARGET_DENIED: "target_denied",
13
+ TENANT_ESCAPE: "tenant_escape",
14
+ HOSTILE_INPUT: "hostile_input",
15
+ EVIDENCE_WRITE_FAILED: "evidence_write_failed",
16
+ REPLAY: "replay_detected",
17
+ POSTCONDITION: "postcondition_discrepancy",
18
+ APPROVAL_REQUIRED: "approval_required",
19
+ INCOMPATIBLE_CONTRACT: "incompatible_contract_version",
20
+ VENDOR_FIELD: "vendor_field_rejected",
21
+ });
22
+
23
+ /** Keys that name a model or agent vendor. They stay outside the contract. */
24
+ export const VENDOR_FIELD_NAMES = Object.freeze([
25
+ "model",
26
+ "modelVendor",
27
+ "agentVendor",
28
+ "agentFramework",
29
+ "llmProvider",
30
+ "openai",
31
+ "anthropic",
32
+ "vendor",
33
+ ]);
34
+
35
+ const VENDOR_FIELD_SET = new Set(VENDOR_FIELD_NAMES);
36
+
37
+ /**
38
+ * Decision / proposal error that carries a stable reason code.
39
+ */
40
+ export class ContractError extends Error {
41
+ /**
42
+ * @param {string} message Operator-facing description (no secrets).
43
+ * @param {string} reason Stable machine reason.
44
+ */
45
+ constructor(message, reason) {
46
+ super(message);
47
+ this.name = "ContractError";
48
+ this.reason = reason;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Reject records that carry a model or agent vendor key.
54
+ *
55
+ * @param {object} record Candidate identity, proposal, or manifest.
56
+ * @returns {void}
57
+ * @throws {ContractError}
58
+ */
59
+ export function assertNoVendorFields(record) {
60
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
61
+ return;
62
+ }
63
+ const keys = Object.keys(record);
64
+ for (const key of keys) {
65
+ if (VENDOR_FIELD_SET.has(key)) {
66
+ throw new ContractError(
67
+ "Model and agent vendor fields are outside the adapter contract.",
68
+ REASON.VENDOR_FIELD,
69
+ );
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {Array<{type: string, value?: string}>} left
76
+ * @param {Array<{type: string, value?: string}>} right
77
+ * @returns {Array<{type: string, value?: string}>}
78
+ */
79
+ export function unionObligations(left = [], right = []) {
80
+ const seen = new Set();
81
+ const out = [];
82
+ for (const item of [...left, ...right]) {
83
+ if (!item || typeof item.type !== "string") continue;
84
+ const key = `${item.type}:${item.value ?? ""}`;
85
+ if (seen.has(key)) continue;
86
+ seen.add(key);
87
+ out.push(Object.freeze({ type: item.type, value: item.value }));
88
+ }
89
+ return out;
90
+ }
91
+
92
+ const RESULT_RANK = new Map([
93
+ ["deny", 0],
94
+ ["require_approval", 1],
95
+ ["allow_with_obligations", 2],
96
+ ["allow", 3],
97
+ ]);
98
+
99
+ /**
100
+ * Compose an optional upstream decision with the local / target decision.
101
+ * Local deny is authoritative. Upstream allow never widens a local deny.
102
+ *
103
+ * @param {object|null|undefined} upstream Upstream evaluator decision.
104
+ * @param {object} local Local / target-side decision.
105
+ * @returns {object} Frozen composed decision.
106
+ */
107
+ export function composeDecisions(upstream, local) {
108
+ if (!upstream) return local;
109
+ if (local.result === "deny") return local;
110
+ if (upstream.result === "deny") return upstream;
111
+
112
+ const localRank = RESULT_RANK.get(local.result) ?? 0;
113
+ const upstreamRank = RESULT_RANK.get(upstream.result) ?? 0;
114
+ const narrower = localRank <= upstreamRank ? local : upstream;
115
+ const obligations = unionObligations(upstream.obligations, local.obligations);
116
+
117
+ if (narrower.result === "require_approval") {
118
+ return Object.freeze({
119
+ ...narrower,
120
+ result: "require_approval",
121
+ obligations: Object.freeze(obligations),
122
+ });
123
+ }
124
+
125
+ if (obligations.length > 0 || narrower.result === "allow_with_obligations") {
126
+ return Object.freeze({
127
+ ...local,
128
+ result: "allow_with_obligations",
129
+ reason: local.reason,
130
+ obligations: Object.freeze(obligations),
131
+ });
132
+ }
133
+
134
+ return Object.freeze({
135
+ ...local,
136
+ result: "allow",
137
+ obligations: Object.freeze([]),
138
+ });
139
+ }
@@ -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
+ }