drupal-mcp-connector 2.11.0 → 2.12.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 CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.12.0] - 2026-09-02
11
+
12
+ ### Security
13
+ - **W&L-operated policy-bundle promotion on the relay edge (#253).** Optional
14
+ `auth.promotions` maps a SHA-256 digest to a sealed portable document plus
15
+ two distinct operator ids. Eligible documents fan down the tenant-agent
16
+ channel after hello; the agent presents them to local enforcement
17
+ (verify / activate / attest). When the table is present, non-diagnostic
18
+ `tools/call` requires the bound `auth.policies` digest **and** a matching
19
+ agent attestation. The edge never mints a Sentinel HMAC key. Omitting
20
+ `auth.promotions` keeps the digest-only path. Lab/loopback only — not
21
+ tenant self-service or a hosted-service claim.
22
+
10
23
  ## [2.11.0] - 2026-09-02
11
24
 
12
25
  ### Security
@@ -1380,6 +1393,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1380
1393
  - User tools gained explicit PII-access assertions.
1381
1394
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1382
1395
 
1396
+ [2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
1383
1397
  [2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
1384
1398
  [2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
1385
1399
  [1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
@@ -39,11 +39,14 @@
39
39
  * auth.actors (sub / azp -> Drupal user UUID) maps the inbound principal
40
40
  * to a Drupal actor for write-like tools. Optional auth.policies
41
41
  * (sub / azp -> SHA-256 digest) is the expected signed policy on the edge.
42
+ * Optional auth.promotions (digest -> sealed document + two operator ids)
43
+ * is the W&L-operated dual-control ledger; the edge fans eligible bundles
44
+ * to the tenant agent and requires a matching local attestation.
42
45
  */
43
46
 
44
47
  import { readFileSync } from "node:fs";
45
48
  import process from "node:process";
46
- import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
49
+ import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundPromotions, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
47
50
  import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
48
51
  import { createRateLimiter } from "../src/lib/rate-limit.js";
49
52
  import {
@@ -131,6 +134,7 @@ try {
131
134
  tenantGrants: getInboundTenantGrants(),
132
135
  actors: getInboundActors(),
133
136
  policies: getInboundPolicies(),
137
+ promotions: getInboundPromotions(),
134
138
  sites,
135
139
  defaultSite: config.defaultSite,
136
140
  channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
@@ -41,6 +41,9 @@
41
41
  "policies": {
42
42
  "_comment": "Optional. Map inbound sub or client_id (azp) to a SHA-256 digest of the expected signed Sentinel policy bundle, e.g. \"content-agent\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\". When present, non-diagnostic tools/call without a mapping are not_entitled. Caller policy/digest arguments are never authority. Omit to keep the prior path (no digest required at the edge). Local verify/activate stays on mcp_sentinel."
43
43
  },
44
+ "promotions": {
45
+ "_comment": "Optional W&L-operated dual-control ledger. Map a SHA-256 digest to { document, approvals } where document is the sealed portable Sentinel bundle (claims + digest + hmac-sha256 seal) and approvals is two distinct operator ids. When present, the edge fans eligible documents to the tenant agent; non-diagnostic tools/call require a matching local attestation. The edge never mints a seal. Omit to keep the digest-only path. Tenant self-service is not this table."
46
+ },
44
47
  "revocationFile": "",
45
48
  "introspectionUrl": "",
46
49
  "introspectionClientIdEnv": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/lib/config.js CHANGED
@@ -292,6 +292,22 @@ export function getInboundPolicies() {
292
292
  return entries.length ? Object.fromEntries(entries) : null;
293
293
  }
294
294
 
295
+ /**
296
+ * W&L-operated dual-control promotion ledger keyed by SHA-256 digest.
297
+ * When present, the edge fans eligible sealed documents to the tenant agent.
298
+ * @returns {object|null}
299
+ */
300
+ export function getInboundPromotions() {
301
+ const promotions = loadConfig().auth?.promotions;
302
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
303
+ return null;
304
+ }
305
+ const entries = Object.entries(promotions)
306
+ .map(([key, value]) => [key.trim(), value])
307
+ .filter(([key]) => key && !key.startsWith("_"));
308
+ return entries.length ? Object.fromEntries(entries) : null;
309
+ }
310
+
295
311
  // ---------------------------------------------------------------------------
296
312
  // Auth headers — never logged, never exposed in tool responses
297
313
  // ---------------------------------------------------------------------------
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Tenant-side local policy enforcement (#253 / DEV-125).
3
+ *
4
+ * Loopback stand-in for mcp_sentinel `McpPolicyBundleRegistry`: mint / verify
5
+ * / activate / simulate / revoke / rollback / emergency deny. The relay edge
6
+ * never receives the signing key and never calls mint. A missing key cannot
7
+ * mint, verify, or activate — emergency deny still arms a deny floor without
8
+ * minting new authority.
9
+ */
10
+
11
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
12
+ import { SEAL_PREFIX } from "./policy-promotion.js";
13
+
14
+ export const POLICY_BUNDLE_VERSION = 1;
15
+
16
+ export const DEFAULT_BUNDLE_TTL = 86400 * 30;
17
+
18
+ export const EMERGENCY_DENY = "*";
19
+
20
+ export const EMERGENCY_DIGEST = "emergency-deny";
21
+
22
+ function normalize(value) {
23
+ if (value === null || typeof value === "string" || typeof value === "number"
24
+ || typeof value === "boolean") {
25
+ return value;
26
+ }
27
+ if (Array.isArray(value)) return value.map(normalize);
28
+ if (value && typeof value === "object") {
29
+ const source = new Map(Object.entries(value));
30
+ const bag = new Map();
31
+ for (const key of [...source.keys()].sort()) {
32
+ bag.set(key, normalize(source.get(key)));
33
+ }
34
+ return Object.fromEntries(bag);
35
+ }
36
+ throw new TypeError("Policy bundle claims may only contain scalars, lists and maps.");
37
+ }
38
+
39
+ /**
40
+ * Canonical JSON of claims (HMAC / digest input). Maps are key-sorted;
41
+ * lists keep caller order. Matches mcp_sentinel `McpPolicyBundle::canonicalJson`.
42
+ *
43
+ * @param {object} claims
44
+ * @returns {string}
45
+ */
46
+ export function canonicalJson(claims) {
47
+ return JSON.stringify(normalize(claims));
48
+ }
49
+
50
+ /**
51
+ * Hex SHA-256 of the canonical claims.
52
+ *
53
+ * @param {object} claims
54
+ * @returns {string}
55
+ */
56
+ export function digestOf(claims) {
57
+ return createHash("sha256").update(canonicalJson(claims)).digest("hex");
58
+ }
59
+
60
+ function safeEqual(left, right) {
61
+ if (typeof left !== "string" || typeof right !== "string") return false;
62
+ const a = Buffer.from(left);
63
+ const b = Buffer.from(right);
64
+ if (a.length !== b.length) return false;
65
+ return timingSafeEqual(a, b);
66
+ }
67
+
68
+ function deniedOperationsOf(claims) {
69
+ const denials = new Map(Object.entries(claims ?? {})).get("denials");
70
+ if (!denials || typeof denials !== "object" || Array.isArray(denials)) return [];
71
+ const ops = new Map(Object.entries(denials)).get("operations");
72
+ if (!Array.isArray(ops)) return [];
73
+ return ops.map(String);
74
+ }
75
+
76
+ function bundleFrom(claims, digest, seal) {
77
+ return Object.freeze({
78
+ claims,
79
+ digest,
80
+ seal,
81
+ version: () => Number(new Map(Object.entries(claims)).get("v") ?? 0),
82
+ expires: () => Number(new Map(Object.entries(claims)).get("expires") ?? 0),
83
+ deniedOperations: () => deniedOperationsOf(claims),
84
+ denies: (operation) => deniedOperationsOf(claims).includes(operation),
85
+ isExpired: (now) => {
86
+ const expires = Number(new Map(Object.entries(claims)).get("expires") ?? 0);
87
+ return expires > 0 && now >= expires;
88
+ },
89
+ toArray: () => ({ ...claims, digest, seal }),
90
+ });
91
+ }
92
+
93
+ /**
94
+ * In-process local enforcement used by the tenant agent in the loopback lab.
95
+ *
96
+ * @param {object} [options]
97
+ * @param {string|null} [options.signingKey] HMAC material. Null = disconnected.
98
+ * @param {() => number} [options.now] Unix seconds.
99
+ * @returns {object}
100
+ */
101
+ export function createLocalPolicyEnforcement({
102
+ signingKey = null,
103
+ now = () => Math.floor(Date.now() / 1000),
104
+ } = {}) {
105
+ let active = null;
106
+ let lastGood = null;
107
+ const revoked = new Map();
108
+
109
+ function canSeal() {
110
+ return typeof signingKey === "string" && signingKey.length > 0;
111
+ }
112
+
113
+ function isRevoked(digest) {
114
+ return revoked.has(digest);
115
+ }
116
+
117
+ function mint(deniedOperations = [], ttl = DEFAULT_BUNDLE_TTL) {
118
+ if (!canSeal()) return null;
119
+ const issued = now();
120
+ const unique = [...new Set((Array.isArray(deniedOperations) ? deniedOperations : [])
121
+ .map(String))];
122
+ const claims = {
123
+ denials: { operations: unique },
124
+ expires: issued + (ttl ?? DEFAULT_BUNDLE_TTL),
125
+ id: randomUUID(),
126
+ issued,
127
+ v: POLICY_BUNDLE_VERSION,
128
+ };
129
+ const digest = digestOf(claims);
130
+ const seal = SEAL_PREFIX + createHmac("sha256", signingKey).update(digest).digest("hex");
131
+ return bundleFrom(claims, digest, seal);
132
+ }
133
+
134
+ function verify(document) {
135
+ if (!canSeal()) return null;
136
+ if (!document || typeof document !== "object" || Array.isArray(document)) return null;
137
+ const bag = new Map(Object.entries(document));
138
+ const seal = bag.get("seal");
139
+ const claimedDigest = bag.get("digest");
140
+ bag.delete("seal");
141
+ bag.delete("digest");
142
+ const claims = Object.fromEntries(bag);
143
+ if (typeof seal !== "string" || !seal.startsWith(SEAL_PREFIX)) return null;
144
+ if (Number(new Map(Object.entries(claims)).get("v") ?? 0) !== POLICY_BUNDLE_VERSION) {
145
+ return null;
146
+ }
147
+ const digest = digestOf(claims);
148
+ if (!safeEqual(digest, typeof claimedDigest === "string" ? claimedDigest : "")) return null;
149
+ const expected = SEAL_PREFIX
150
+ + createHmac("sha256", signingKey).update(digest).digest("hex");
151
+ if (!safeEqual(expected, seal)) return null;
152
+ const bundle = bundleFrom(claims, digest, seal);
153
+ if (bundle.isExpired(now())) return null;
154
+ if (isRevoked(digest)) return null;
155
+ return bundle;
156
+ }
157
+
158
+ function attestation() {
159
+ return active;
160
+ }
161
+
162
+ function activeDigest() {
163
+ const digest = active && typeof active.digest === "string" ? active.digest : null;
164
+ return digest || null;
165
+ }
166
+
167
+ function emergencyDeny() {
168
+ if (active && typeof active.digest === "string") lastGood = active;
169
+ active = {
170
+ digest: EMERGENCY_DIGEST,
171
+ activated_at: now(),
172
+ previous: active && typeof active.digest === "string" ? active.digest : null,
173
+ emergency: true,
174
+ bundle: {
175
+ v: POLICY_BUNDLE_VERSION,
176
+ denials: { operations: [EMERGENCY_DENY] },
177
+ expires: 0,
178
+ id: EMERGENCY_DIGEST,
179
+ issued: now(),
180
+ },
181
+ };
182
+ }
183
+
184
+ function activateBundle(bundle) {
185
+ if (!canSeal() || !bundle) return null;
186
+ if (active && typeof active.digest === "string") lastGood = active;
187
+ const previous = active && typeof active.digest === "string" ? active.digest : null;
188
+ active = {
189
+ digest: bundle.digest,
190
+ activated_at: now(),
191
+ previous,
192
+ bundle: bundle.toArray(),
193
+ };
194
+ return {
195
+ digest: bundle.digest,
196
+ activated_at: active.activated_at,
197
+ previous,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Verify then activate a portable document. This is the agent hook.
203
+ *
204
+ * @param {object} document
205
+ * @returns {{ok: boolean, digest?: string, attested?: boolean, reason?: string}}
206
+ */
207
+ function activate(document) {
208
+ const bundle = verify(document);
209
+ if (!bundle) return { ok: false, reason: "unverified" };
210
+ const result = activateBundle(bundle);
211
+ if (!result) return { ok: false, reason: "cannot_activate" };
212
+ return { ok: true, digest: result.digest, attested: true };
213
+ }
214
+
215
+ function simulate(operation, localDenies, candidate = null) {
216
+ const digest = candidate?.digest ?? activeDigest();
217
+ if (localDenies) {
218
+ return { allow: false, reason: "local_deny", digest };
219
+ }
220
+ if (candidate === null && active && active.emergency) {
221
+ return {
222
+ allow: false,
223
+ reason: "emergency_deny",
224
+ digest: digest ?? EMERGENCY_DIGEST,
225
+ };
226
+ }
227
+ let bundle = candidate;
228
+ if (bundle === null) {
229
+ const document = active?.bundle ?? null;
230
+ bundle = document ? verify(document) : null;
231
+ if (bundle === null && digest) {
232
+ return { allow: false, reason: "bundle_unverified", digest };
233
+ }
234
+ }
235
+ if (bundle && (bundle.denies(operation) || bundle.denies(EMERGENCY_DENY))) {
236
+ return { allow: false, reason: "bundle_deny", digest: bundle.digest };
237
+ }
238
+ return { allow: true, reason: "allow", digest };
239
+ }
240
+
241
+ function revoke(digest) {
242
+ if (typeof digest !== "string" || !digest) return;
243
+ revoked.set(digest, now());
244
+ if (activeDigest() === digest) emergencyDeny();
245
+ }
246
+
247
+ function rollback() {
248
+ if (!lastGood || typeof lastGood.digest !== "string") return null;
249
+ if (isRevoked(lastGood.digest)) return null;
250
+ active = lastGood;
251
+ return lastGood;
252
+ }
253
+
254
+ return {
255
+ canSeal,
256
+ mint,
257
+ verify,
258
+ activate,
259
+ activateBundle,
260
+ attestation,
261
+ activeDigest,
262
+ simulate,
263
+ revoke,
264
+ rollback,
265
+ emergencyDeny,
266
+ isRevoked,
267
+ };
268
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * W&L-operated dual-control promotion ledger (#253 / DEV-125).
3
+ *
4
+ * The edge never mints a Sentinel HMAC seal. A promotion is an already-sealed
5
+ * portable document plus two distinct operator ids. Eligibility is the gate
6
+ * before the tenant agent may receive the artifact. Caller-supplied documents
7
+ * are never read here.
8
+ */
9
+
10
+ export const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
11
+
12
+ export const SEAL_PREFIX = "hmac-sha256:";
13
+
14
+ function tableHasKeys(table) {
15
+ return Object.keys(table).some((key) => {
16
+ const id = key.trim();
17
+ return id && !id.startsWith("_");
18
+ });
19
+ }
20
+
21
+ function uniqueApprovals(raw) {
22
+ if (!Array.isArray(raw)) return [];
23
+ const seen = new Set();
24
+ const out = [];
25
+ for (const value of raw) {
26
+ const id = String(value).trim();
27
+ if (!id || id.startsWith("_") || seen.has(id)) continue;
28
+ seen.add(id);
29
+ out.push(id);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function portableDocument(raw) {
35
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
36
+ const bag = new Map(Object.entries(raw));
37
+ const digest = typeof bag.get("digest") === "string"
38
+ ? bag.get("digest").trim().toLowerCase()
39
+ : "";
40
+ const seal = typeof bag.get("seal") === "string" ? bag.get("seal").trim() : "";
41
+ if (!POLICY_DIGEST.test(digest)) return null;
42
+ if (!seal.startsWith(SEAL_PREFIX) || seal.length <= SEAL_PREFIX.length) return null;
43
+ bag.set("digest", digest);
44
+ bag.set("seal", seal);
45
+ return Object.freeze(Object.fromEntries(bag));
46
+ }
47
+
48
+ /**
49
+ * Normalize `auth.promotions` (digest → sealed document + approvals).
50
+ * Comment keys and malformed rows are dropped. Duplicate operator ids in
51
+ * `approvals` count once. A row is eligible only with two distinct operators
52
+ * and a document whose claimed digest matches the map key.
53
+ *
54
+ * @param {object|null} promotions
55
+ * @returns {object|null}
56
+ */
57
+ export function normalizePromotions(promotions) {
58
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
59
+ return null;
60
+ }
61
+ const entries = [];
62
+ for (const [rawKey, value] of Object.entries(promotions)) {
63
+ const key = rawKey.trim().toLowerCase();
64
+ if (!key || key.startsWith("_") || !POLICY_DIGEST.test(key)) continue;
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
66
+ const bag = new Map(Object.entries(value));
67
+ const document = portableDocument(bag.get("document"));
68
+ if (!document || document.digest !== key) continue;
69
+ const approvals = uniqueApprovals(bag.get("approvals"));
70
+ entries.push([key, Object.freeze({
71
+ digest: key,
72
+ document,
73
+ approvals: Object.freeze(approvals),
74
+ eligible: approvals.length >= 2,
75
+ })]);
76
+ }
77
+ return entries.length ? Object.fromEntries(entries) : null;
78
+ }
79
+
80
+ /**
81
+ * Whether a promotions table is in force (fail-closed even when every row
82
+ * is ineligible). Comment-only objects are not in force.
83
+ *
84
+ * @param {object|null} promotions
85
+ * @returns {boolean}
86
+ */
87
+ export function promotionsRequired(promotions) {
88
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
89
+ return false;
90
+ }
91
+ return tableHasKeys(promotions);
92
+ }
93
+
94
+ /**
95
+ * Eligible sealed documents ready to fan down.
96
+ *
97
+ * @param {object|null} promotions
98
+ * @returns {Array<{digest: string, document: object, approvals: string[]}>}
99
+ */
100
+ export function eligiblePromotions(promotions) {
101
+ const table = normalizePromotions(promotions);
102
+ if (!table) return [];
103
+ return Object.values(table).filter((row) => row.eligible);
104
+ }
105
+
106
+ /**
107
+ * Look up one digest in the ledger.
108
+ *
109
+ * @param {object} params
110
+ * @param {string|null} [params.digest]
111
+ * @param {object|null} [params.promotions]
112
+ * @returns {{document: object|null, eligible: boolean, reason: "not_entitled"|null}}
113
+ */
114
+ export function resolveEligiblePromotion({ digest = null, promotions = null } = {}) {
115
+ const required = promotionsRequired(promotions);
116
+ const table = normalizePromotions(promotions);
117
+ if (!table) {
118
+ return {
119
+ document: null,
120
+ eligible: false,
121
+ reason: required ? "not_entitled" : null,
122
+ };
123
+ }
124
+ const key = typeof digest === "string" ? digest.trim().toLowerCase() : "";
125
+ const record = key ? new Map(Object.entries(table)).get(key) : null;
126
+ if (!record || !record.eligible) {
127
+ return { document: null, eligible: false, reason: "not_entitled" };
128
+ }
129
+ return { document: record.document, eligible: true, reason: null };
130
+ }
@@ -16,6 +16,7 @@
16
16
  import { connect as netConnect } from "node:net";
17
17
  import { createMcpHandler } from "@modelcontextprotocol/server";
18
18
  import { createConnectorServerFactory } from "../mcp-server.js";
19
+ import { POLICY_DIGEST } from "../policy-promotion.js";
19
20
  import { runWithIdentity } from "../principal.js";
20
21
  import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
21
22
 
@@ -35,6 +36,9 @@ import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
35
36
  * @param {?() => void} [options.onChannelClose] Called when an established
36
37
  * channel is lost (not on deliberate `drop`/`close`), so an entry point
37
38
  * can fail loudly instead of idling disconnected.
39
+ * @param {?{activate: Function}} [options.policyEnforcement] Local Sentinel
40
+ * hook. `activate(document)` verifies, activates, and attests. Missing
41
+ * hook fail-closes a `policy-bundle` frame (no mint on this process).
38
42
  * @param {?{recordConnect: Function}} [options.ledger]
39
43
  * @returns {object}
40
44
  */
@@ -45,6 +49,7 @@ export function createRelayAgent({
45
49
  surface,
46
50
  connectFn = netConnect,
47
51
  onChannelClose = null,
52
+ policyEnforcement = null,
48
53
  ledger = null,
49
54
  }) {
50
55
  if (!host || !port) {
@@ -63,6 +68,41 @@ export function createRelayAgent({
63
68
  let socket = null;
64
69
  let agentInfo = null;
65
70
 
71
+ async function presentBundle(activeSocket, frame) {
72
+ let ok = false;
73
+ let digest = "";
74
+ let reason = "no_enforcement";
75
+ try {
76
+ if (policyEnforcement && typeof policyEnforcement.activate === "function") {
77
+ const result = await policyEnforcement.activate(frame.document);
78
+ const claimed = typeof result?.digest === "string"
79
+ ? result.digest.trim().toLowerCase()
80
+ : "";
81
+ ok = result?.ok === true && POLICY_DIGEST.test(claimed);
82
+ digest = ok ? claimed : "";
83
+ reason = ok
84
+ ? ""
85
+ : (typeof result?.reason === "string" && result.reason.trim()
86
+ ? result.reason.trim().slice(0, 64)
87
+ : "unverified");
88
+ }
89
+ } catch {
90
+ ok = false;
91
+ digest = "";
92
+ reason = "activate_failed";
93
+ }
94
+ try {
95
+ writeFrame(activeSocket, {
96
+ type: "policy-bundle-ack",
97
+ ok,
98
+ digest,
99
+ ...(ok ? {} : { reason }),
100
+ });
101
+ } catch {
102
+ activeSocket.destroy();
103
+ }
104
+ }
105
+
66
106
  async function serveFrame(activeSocket, frame) {
67
107
  if (frame.type !== "mcp-request") return;
68
108
  if (!frame.identity || typeof frame.identity !== "object" || Array.isArray(frame.identity)) {
@@ -138,6 +178,10 @@ export function createRelayAgent({
138
178
  resolve({ ok: false, reason: frame.reason });
139
179
  return;
140
180
  }
181
+ if (frame.type === "policy-bundle") {
182
+ void presentBundle(next, frame);
183
+ return;
184
+ }
141
185
  void serveFrame(next, frame);
142
186
  });
143
187
  next.on("error", reject);
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Relay northbound edge (#232, #242, #244, #247, #250) — DEV-294 AC4, DEV-122
3
- * isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125 policy digest.
2
+ * Relay northbound edge (#232, #242, #244, #247, #250, #253) — DEV-294 AC4,
3
+ * DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125
4
+ * policy digest and W&L-operated bundle promotion.
4
5
  *
5
6
  * Terminates northbound MCP over the OAuth resource server and fans requests
6
7
  * down outbound tenant-agent channels. The edge proposes; the tenant-side
@@ -38,6 +39,12 @@ import { createLocalRelay } from "../contracts/relay.js";
38
39
  import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
39
40
  import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
40
41
  import { isWriteLikeCall } from "../operations.js";
42
+ import {
43
+ eligiblePromotions,
44
+ POLICY_DIGEST,
45
+ promotionsRequired,
46
+ resolveEligiblePromotion,
47
+ } from "../policy-promotion.js";
41
48
  import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
42
49
  import {
43
50
  attachFramer,
@@ -469,6 +476,10 @@ function jsonResponse(res, status, body) {
469
476
  * @param {object|null} [options.actors] Optional principal → Drupal actor
470
477
  * table (`sub` / `azp` → `{ uuid, delegators? }`). When present, write-like
471
478
  * tools/call require a mapping.
479
+ * @param {object|null} [options.policies] Optional principal → SHA-256 digest.
480
+ * @param {object|null} [options.promotions] Optional W&L-operated dual-control
481
+ * ledger (digest → sealed document + two operator ids). When present,
482
+ * non-diagnostic tools/call require a matching agent attestation.
472
483
  * @param {Array<{_name: string}>} options.sites Credential-free catalog.
473
484
  * @param {string} [options.defaultSite]
474
485
  * @param {{lookup: Function}} options.channelCredentials Agent channel store.
@@ -490,6 +501,7 @@ export async function startEdge({
490
501
  tenantGrants = null,
491
502
  actors = null,
492
503
  policies = null,
504
+ promotions = null,
493
505
  sites,
494
506
  defaultSite,
495
507
  channelCredentials,
@@ -525,6 +537,10 @@ export async function startEdge({
525
537
  const policyTable = policies && typeof policies === "object" && !Array.isArray(policies)
526
538
  ? policies
527
539
  : null;
540
+ const promotionTable = promotions && typeof promotions === "object" && !Array.isArray(promotions)
541
+ ? promotions
542
+ : null;
543
+ const promoRequired = promotionsRequired(promotionTable);
528
544
  if (typeof channelCredentials?.lookup !== "function") {
529
545
  throw new EdgeStartupError(
530
546
  "Relay edge requires an agent channel credential store; without one no "
@@ -550,7 +566,7 @@ export async function startEdge({
550
566
  const targetRelay = createLocalRelay({ sites: catalog, grants: grantTable, defaultSite });
551
567
  const broker = createRequestBroker({ timeoutMs: fanDownTimeoutMs });
552
568
 
553
- /** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null}>} */
569
+ /** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null, attestedDigests: Set<string>, offeredDigests: Set<string>}>} */
554
570
  const sessions = new Map();
555
571
  const catalogNames = catalog.map((site) => site._name);
556
572
 
@@ -580,20 +596,47 @@ export async function startEdge({
580
596
  const existing = sessions.get(record.agentId);
581
597
  if (existing && existing.socket !== socket) existing.socket.destroy();
582
598
  agentId = record.agentId;
599
+ const offeredDigests = new Set();
583
600
  sessions.set(agentId, {
584
601
  socket,
585
602
  token: frame.token,
586
603
  agentId,
587
604
  sites: decision.sites,
605
+ attestedDigests: new Set(),
606
+ offeredDigests,
588
607
  });
589
- writeFrame(socket, { type: "hello-ok", agent: { agentId } });
608
+ try {
609
+ for (const row of eligiblePromotions(promotionTable)) {
610
+ const wrote = writeFrame(socket, { type: "policy-bundle", document: row.document });
611
+ if (!wrote) throw new Error("policy-bundle write failed");
612
+ offeredDigests.add(row.digest);
613
+ }
614
+ writeFrame(socket, { type: "hello-ok", agent: { agentId } });
615
+ } catch {
616
+ sessions.delete(agentId);
617
+ agentId = null;
618
+ socket.destroy();
619
+ }
590
620
  return;
591
621
  }
592
- if (frame.type === "mcp-response") {
593
- if (!agentId) {
594
- socket.destroy();
595
- return;
622
+ if (!agentId) {
623
+ socket.destroy();
624
+ return;
625
+ }
626
+ if (frame.type === "policy-bundle-ack") {
627
+ const session = sessions.get(agentId);
628
+ const digest = typeof frame.digest === "string"
629
+ ? frame.digest.trim().toLowerCase()
630
+ : "";
631
+ if (
632
+ session && frame.ok === true && POLICY_DIGEST.test(digest)
633
+ && session.offeredDigests.has(digest)
634
+ ) {
635
+ session.attestedDigests.add(digest);
596
636
  }
637
+ return;
638
+ }
639
+ if (frame.type === "mcp-response") {
597
640
  broker.settle(frame, { owner: agentId });
598
641
  return;
599
642
  }
@@ -649,13 +692,17 @@ export async function startEdge({
649
692
  jsonResponse(res, 403, { error: "not_entitled" });
650
693
  return;
651
694
  }
695
+ const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
652
696
  if (
653
- boundPolicy.required && boundPolicy.reason && isCall
654
- && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName))
697
+ boundPolicy.required && boundPolicy.reason && policyCall
655
698
  ) {
656
699
  jsonResponse(res, 403, { error: "not_entitled" });
657
700
  return;
658
701
  }
702
+ if (promoRequired && policyCall && !boundPolicy.policy) {
703
+ jsonResponse(res, 403, { error: "not_entitled" });
704
+ return;
705
+ }
659
706
 
660
707
  const selected = resolveTenantRoute({
661
708
  identity,
@@ -665,6 +712,17 @@ export async function startEdge({
665
712
  targetName,
666
713
  sessions: [...sessions.values()],
667
714
  });
715
+ if (promoRequired && policyCall) {
716
+ const promo = resolveEligiblePromotion({
717
+ digest: boundPolicy.policy,
718
+ promotions: promotionTable,
719
+ });
720
+ const attested = selected.session?.attestedDigests;
721
+ if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
722
+ jsonResponse(res, 403, { error: "not_entitled" });
723
+ return;
724
+ }
725
+ }
668
726
  if (!selected.session) {
669
727
  const entitled = selected.reason === "not_entitled";
670
728
  jsonResponse(res, entitled ? 403 : 503, {
@@ -21,6 +21,8 @@ export const FRAME_TYPES = Object.freeze([
21
21
  "denied",
22
22
  "mcp-request",
23
23
  "mcp-response",
24
+ "policy-bundle",
25
+ "policy-bundle-ack",
24
26
  ]);
25
27
 
26
28
  const FRAME_TYPE_SET = new Set(FRAME_TYPES);