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.
- package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
- package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
- package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
- package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
- package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
- package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-search.md +3 -4
- package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
- package/CHANGELOG.md +82 -0
- package/README.md +23 -12
- package/config/config.example.json +1 -1
- package/package.json +4 -2
- package/scripts/generate-commands.js +42 -21
- package/scripts/install-commands.js +157 -0
- package/src/lib/backends/backend-interface.js +20 -1
- package/src/lib/backends/jsonapi.js +47 -4
- package/src/lib/body-summary.js +60 -0
- package/src/lib/contracts/approval.js +72 -0
- package/src/lib/contracts/decisions.js +139 -0
- package/src/lib/contracts/drupal.js +525 -0
- package/src/lib/contracts/evaluator.js +28 -0
- package/src/lib/contracts/evidence-sink.js +67 -0
- package/src/lib/contracts/fixtures.js +117 -0
- package/src/lib/contracts/index.js +46 -0
- package/src/lib/contracts/relay.js +83 -0
- package/src/lib/contracts/system-of-record.js +61 -0
- package/src/lib/contracts/types.js +242 -0
- package/src/lib/contracts/version.js +52 -0
- package/src/lib/data-flow.js +437 -0
- package/src/lib/dispatch.js +36 -15
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +14 -0
- package/src/lib/server-tools.js +9 -0
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +3 -2
- 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
|
+
}
|