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 +14 -0
- package/bin/drupal-mcp-edge.js +5 -1
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/src/lib/config.js +16 -0
- package/src/lib/policy-enforcement.js +268 -0
- package/src/lib/policy-promotion.js +130 -0
- package/src/lib/relay/agent.js +44 -0
- package/src/lib/relay/edge.js +68 -10
- package/src/lib/relay/frames.js +2 -0
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
|
package/bin/drupal-mcp-edge.js
CHANGED
|
@@ -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
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
|
+
}
|
package/src/lib/relay/agent.js
CHANGED
|
@@ -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);
|
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232, #242, #244, #247, #250) — DEV-294 AC4,
|
|
3
|
-
* isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125
|
|
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
|
-
|
|
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 (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 &&
|
|
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, {
|