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.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -1
- package/package.json +1 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.9.0] - 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Adapter-contract execute trusts a fresh evaluation (#181).**
|
|
14
|
+
Manifest digests are always hashed (caller-supplied digests are ignored).
|
|
15
|
+
`execute` re-evaluates and will not honor a forged allow. Actor-bound
|
|
16
|
+
approvals cannot be consumed without that actor. Vendor keys are rejected
|
|
17
|
+
on `hints`. `propose` and `policyDigest` use the same relay hint keys as
|
|
18
|
+
evaluate. A required-evidence failure after a backend write rolls the
|
|
19
|
+
mutation back. `publish` with an id updates that entity instead of
|
|
20
|
+
creating a duplicate.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Provider-neutral adapter contracts and a Drupal conformance kit (#181).**
|
|
24
|
+
Versioned evaluator, relay, approval, evidence-sink, and system-of-record
|
|
25
|
+
contracts live at `src/lib/contracts/` (contract 1.0). Typed decisions
|
|
26
|
+
(`deny` / `allow` / `allow_with_obligations` / `require_approval`), stable
|
|
27
|
+
reason codes, obligations, and execution receipts are verified at that
|
|
28
|
+
seam. Final target-side denial stays authoritative: an upstream allow
|
|
29
|
+
cannot widen local policy. Model and agent vendors are outside the
|
|
30
|
+
contract. The Drupal adapter is the only system-of-record implementation;
|
|
31
|
+
JSON:API and GraphQL remain transport adapters. The offline conformance
|
|
32
|
+
kit covers allowed and denied actions, hostile input, tenant escape,
|
|
33
|
+
required-evidence write failure, replay, and post-condition discrepancy.
|
|
34
|
+
See [docs/adapter-contracts.md](docs/adapter-contracts.md).
|
|
35
|
+
|
|
10
36
|
## [2.8.0] - 2026-08-25
|
|
11
37
|
|
|
12
38
|
### Fixed
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
|
|
11
11
|
|
|
12
|
-
**If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to
|
|
12
|
+
**If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -308,6 +308,7 @@ an operator channel: keep the agent's credentials off it, and pin
|
|
|
308
308
|
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
309
309
|
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
310
310
|
| [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
|
|
311
|
+
| [Adapter Contracts](docs/adapter-contracts.md) | Provider-neutral evaluator / relay / approval / evidence / SoR contracts and the Drupal conformance kit |
|
|
311
312
|
| [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
|
|
312
313
|
| [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
|
|
313
314
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-interface contract (#181).
|
|
3
|
+
*
|
|
4
|
+
* An approval binds to an action-manifest digest and a single actor. It is
|
|
5
|
+
* one-use. Replay, digest mismatch, or actor mismatch invalidate it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { ContractError, REASON } from "./decisions.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} ApprovalInterface
|
|
13
|
+
* @property {(manifest: object, actor?: string) => {approvalId: string, digest: string}} issue
|
|
14
|
+
* @property {(approvalId: string, digest: string, actor?: string) => {approvalId: string, digest: string}} consume
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-process one-use approval ledger.
|
|
19
|
+
* @returns {ApprovalInterface & {size: () => number}}
|
|
20
|
+
*/
|
|
21
|
+
export function createMemoryApproval() {
|
|
22
|
+
const store = new Map();
|
|
23
|
+
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} manifest
|
|
27
|
+
* @param {string} [actor]
|
|
28
|
+
* @returns {{approvalId: string, digest: string}}
|
|
29
|
+
*/
|
|
30
|
+
issue(manifest, actor) {
|
|
31
|
+
if (!manifest?.digest) {
|
|
32
|
+
throw new ContractError("Approval requires a manifest digest.", REASON.APPROVAL_REQUIRED);
|
|
33
|
+
}
|
|
34
|
+
const approvalId = randomUUID();
|
|
35
|
+
store.set(approvalId, {
|
|
36
|
+
digest: manifest.digest,
|
|
37
|
+
actor: actor ?? null,
|
|
38
|
+
used: false,
|
|
39
|
+
});
|
|
40
|
+
return { approvalId, digest: manifest.digest };
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} approvalId
|
|
45
|
+
* @param {string} digest
|
|
46
|
+
* @param {string} [actor]
|
|
47
|
+
* @returns {{approvalId: string, digest: string}}
|
|
48
|
+
*/
|
|
49
|
+
consume(approvalId, digest, actor) {
|
|
50
|
+
if (!approvalId) {
|
|
51
|
+
throw new ContractError("Approval required.", REASON.APPROVAL_REQUIRED);
|
|
52
|
+
}
|
|
53
|
+
const entry = store.get(approvalId);
|
|
54
|
+
if (!entry || entry.used) {
|
|
55
|
+
throw new ContractError("Approval already used or unknown.", REASON.REPLAY);
|
|
56
|
+
}
|
|
57
|
+
if (entry.digest !== digest) {
|
|
58
|
+
throw new ContractError("Approval digest mismatch.", REASON.REPLAY);
|
|
59
|
+
}
|
|
60
|
+
if (entry.actor && entry.actor !== actor) {
|
|
61
|
+
throw new ContractError("Approval actor mismatch.", REASON.REPLAY);
|
|
62
|
+
}
|
|
63
|
+
store.set(approvalId, { ...entry, used: true });
|
|
64
|
+
return { approvalId, digest };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** @returns {number} */
|
|
68
|
+
size() {
|
|
69
|
+
return store.size;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -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
|
+
}
|