drupal-mcp-connector 2.10.1 → 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 +66 -0
- package/bin/drupal-mcp-edge.js +17 -3
- package/config/config.example.json +12 -0
- package/package.json +1 -1
- package/src/lib/backends/jsonapi.js +32 -3
- package/src/lib/config.js +64 -0
- package/src/lib/http-auth.js +5 -1
- package/src/lib/policy-enforcement.js +268 -0
- package/src/lib/policy-promotion.js +130 -0
- package/src/lib/principal.js +159 -4
- package/src/lib/relay/agent.js +44 -0
- package/src/lib/relay/edge.js +426 -37
- package/src/lib/relay/frames.js +37 -9
- package/src/tools/site.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,70 @@ 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
|
+
|
|
23
|
+
## [2.11.0] - 2026-09-02
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
- **Expected policy digest on the relay edge (#250).** Optional
|
|
27
|
+
`auth.policies` maps inbound `sub` (then `azp`) to a SHA-256 digest.
|
|
28
|
+
When that table is present, non-diagnostic `tools/call` without a
|
|
29
|
+
mapping is `not_entitled` with no frames (including a call that omits
|
|
30
|
+
the tool name, and even when no agent is connected). The granted digest
|
|
31
|
+
is stamped on `identity.policy` and `correlation.policyDigest`. Caller
|
|
32
|
+
`policy` / `digest` arguments are ignored and stripped. Diagnostic
|
|
33
|
+
tools stay callable. Omitting `auth.policies` keeps the prior path.
|
|
34
|
+
Lab/loopback only — not dual-control promotion, bundle distribution, or
|
|
35
|
+
a hosted-service claim.
|
|
36
|
+
- **Principal → Drupal actor mapping on the relay edge (#247).** Optional
|
|
37
|
+
`auth.actors` maps inbound `sub` (then `azp`) to a Drupal user UUID. When
|
|
38
|
+
that table is present, write-like `tools/call` without a mapping is
|
|
39
|
+
`not_entitled` with no frames. The granted actor is stamped on
|
|
40
|
+
`identity.actor`; JWT `act.sub` is a confirming hint inside `delegators`
|
|
41
|
+
and becomes `identity.delegator`. Caller `actor` / `delegator` arguments
|
|
42
|
+
and spoofable identity headers are ignored and stripped from the framed
|
|
43
|
+
body. JSON:API writes attach `relationships.uid` from the grant. The
|
|
44
|
+
northbound JWT is never the southbound Drupal credential. Omitting
|
|
45
|
+
`auth.actors` keeps writes on the site OAuth consumer's owner. Lab/loopback
|
|
46
|
+
only — not a live revision-uid or public-URL claim.
|
|
47
|
+
- **Authoritative tenant routing on the relay edge (#244).** Optional
|
|
48
|
+
`auth.tenantGrants` maps inbound client id to tenant agent ids. When that
|
|
49
|
+
table is present, the edge selects the tenant from the grant (JWT `azp`),
|
|
50
|
+
not from caller `tenant` / `site` input. A hint for another tenant is
|
|
51
|
+
`not_entitled` with no frames on any tunnel. The granted tenant is stamped
|
|
52
|
+
on `identity.tenant` and on `correlation: { requestId, tenant, target,
|
|
53
|
+
source }`; the caller `tenant` argument is stripped from the framed body.
|
|
54
|
+
`drupal_list_sites` reports grant-sourced `tenants`. Installs without
|
|
55
|
+
`tenantGrants` keep the site-derived unique-agent path. Lab/loopback
|
|
56
|
+
token-resolved tenancy only — not a public hostname or path-prefix claim.
|
|
57
|
+
- **Two-tenant isolation on the relay edge (#242).** The edge keeps one
|
|
58
|
+
agent-channel session per `agentId` instead of replacing the previous
|
|
59
|
+
tenant. Channel records may bind `sites` (catalog names); that bind is
|
|
60
|
+
the tenant boundary. A second unscoped agent is denied `unbound_tenant`,
|
|
61
|
+
overlapping site claims are denied `overlapping_tenant`, and fan-down
|
|
62
|
+
selects the unique agent granted to the northbound principal. Cross-tenant
|
|
63
|
+
hints still fail closed before any frame reaches the wrong process. A
|
|
64
|
+
tenant disconnect rejects only that tenant's in-flight requests. A
|
|
65
|
+
spoofed `mcp-response` from another agent cannot complete the wait.
|
|
66
|
+
A hot-reload that changes a connected agent's `sites` bind drops that
|
|
67
|
+
tunnel on the next request (`no_agent`) instead of routing on the stale
|
|
68
|
+
hello snapshot.
|
|
69
|
+
Fan-down frames carry `{ requestId, tenant, target, source }` correlation
|
|
70
|
+
and still never carry site secrets, the channel token, or the northbound JWT. A single
|
|
71
|
+
unscoped agent remains the previous compatibility path. Lab/loopback
|
|
72
|
+
proof only — not a hosted-service or public-URL claim.
|
|
73
|
+
|
|
10
74
|
## [2.10.1] - 2026-08-28
|
|
11
75
|
|
|
12
76
|
### Fixed
|
|
@@ -1329,6 +1393,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1329
1393
|
- User tools gained explicit PII-access assertions.
|
|
1330
1394
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1331
1395
|
|
|
1396
|
+
[2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
|
|
1397
|
+
[2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
|
|
1332
1398
|
[2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
|
|
1333
1399
|
[1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
|
|
1334
1400
|
[0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
|
package/bin/drupal-mcp-edge.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* MCP_CHANNEL_CREDENTIALS_FILE
|
|
17
17
|
* Agent channel credential store (or config
|
|
18
18
|
* relay.channelCredentialsFile). Required. JSON:
|
|
19
|
-
* {"agents": {"<id>": {"tokenSha256": "<hex>"
|
|
19
|
+
* {"agents": {"<id>": {"tokenSha256": "<hex>",
|
|
20
|
+
* "sites": ["site-name"]}}}. `sites` binds the tunnel
|
|
21
|
+
* to catalog names (the tenant boundary). Omit `sites`
|
|
22
|
+
* only for a single-agent install.
|
|
20
23
|
* MCP_EDGE_PORT Northbound port (default: MCP_PORT / config tls.port).
|
|
21
24
|
* MCP_EDGE_AGENT_PORT
|
|
22
25
|
* Agent channel port (default: northbound port + 1).
|
|
@@ -31,12 +34,19 @@
|
|
|
31
34
|
* primary entry point).
|
|
32
35
|
*
|
|
33
36
|
* Config: auth.grants (client id -> [site names]) is mandatory; the edge
|
|
34
|
-
* refuses to start without it.
|
|
37
|
+
* refuses to start without it. Optional auth.tenantGrants (client id ->
|
|
38
|
+
* [tenant agent ids]) makes tenant routing grant-authoritative. Optional
|
|
39
|
+
* auth.actors (sub / azp -> Drupal user UUID) maps the inbound principal
|
|
40
|
+
* to a Drupal actor for write-like tools. Optional auth.policies
|
|
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.
|
|
35
45
|
*/
|
|
36
46
|
|
|
37
47
|
import { readFileSync } from "node:fs";
|
|
38
48
|
import process from "node:process";
|
|
39
|
-
import { getInboundGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
|
|
49
|
+
import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundPromotions, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
|
|
40
50
|
import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
|
|
41
51
|
import { createRateLimiter } from "../src/lib/rate-limit.js";
|
|
42
52
|
import {
|
|
@@ -121,6 +131,10 @@ try {
|
|
|
121
131
|
edge = await startEdge({
|
|
122
132
|
auth: inboundCfg,
|
|
123
133
|
grants,
|
|
134
|
+
tenantGrants: getInboundTenantGrants(),
|
|
135
|
+
actors: getInboundActors(),
|
|
136
|
+
policies: getInboundPolicies(),
|
|
137
|
+
promotions: getInboundPromotions(),
|
|
124
138
|
sites,
|
|
125
139
|
defaultSite: config.defaultSite,
|
|
126
140
|
channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
|
|
@@ -32,6 +32,18 @@
|
|
|
32
32
|
"grants": {
|
|
33
33
|
"_comment": "Optional. Map inbound OAuth client_id (azp) to site names, e.g. \"content-agent\": [\"production\", \"staging\"]. When the map names any client, unknown clients receive no sites. Omit this object (or leave only this comment) to let every resolvable site stay visible; inbound scopes still gate the tool surface."
|
|
34
34
|
},
|
|
35
|
+
"tenantGrants": {
|
|
36
|
+
"_comment": "Optional on the library path; used by drupal-mcp-edge for token-resolved tenancy. Map inbound client_id (azp) to tenant agent ids, e.g. \"content-agent\": [\"tenant-a\"]. Caller tenant/site arguments are hints checked against this grant, never authority. Omit to derive the tenant from the unique agent covering the site grant."
|
|
37
|
+
},
|
|
38
|
+
"actors": {
|
|
39
|
+
"_comment": "Optional. Map inbound sub or client_id (azp) to a Drupal user UUID for write authorship, e.g. \"content-agent\": { \"uuid\": \"11111111-1111-4111-8111-111111111111\", \"delegators\": [\"operator-1\"] }. When present, write-like tools/call without a mapping are not_entitled. JWT act.sub is a confirming hint inside delegators. Caller actor/delegator arguments are never authority. Omit to keep writes on the site OAuth consumer's owner account."
|
|
40
|
+
},
|
|
41
|
+
"policies": {
|
|
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
|
+
},
|
|
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
|
+
},
|
|
35
47
|
"revocationFile": "",
|
|
36
48
|
"introspectionUrl": "",
|
|
37
49
|
"introspectionClientIdEnv": "",
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
11
|
+
import { getRequestIdentity } from "../principal.js";
|
|
11
12
|
import { validateUuid, validateMachineName } from "../validate.js";
|
|
12
13
|
import { parseFieldConfigObject } from "../field-definition.js";
|
|
13
14
|
import { Backend } from "./backend-interface.js";
|
|
@@ -134,8 +135,34 @@ function applyFilter(params, { field, op = "eq", value }) {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
/**
|
|
137
|
-
*
|
|
138
|
+
* Bind JSON:API `uid` from the grant-stamped identity. Caller uid is overwritten.
|
|
139
|
+
* User entities are left unchanged. No actor claim (auth.actors not in effect
|
|
140
|
+
* for this principal) leaves relationships as-is — the prior, pre-DEV-123 path.
|
|
141
|
+
* A *present* actor claim that fails UUID validation fails closed (throws)
|
|
142
|
+
* rather than silently keeping a caller-supplied uid: resolveActor()/
|
|
143
|
+
* normalizeActors() already validate the shape before stamping identity.actor,
|
|
144
|
+
* so this should be unreachable in the normal path — it exists so a future
|
|
145
|
+
* bug upstream of this call can never downgrade into "trust the caller".
|
|
146
|
+
*
|
|
147
|
+
* @param {string} entityType
|
|
148
|
+
* @param {object|undefined} relationships
|
|
149
|
+
* @returns {object|undefined}
|
|
138
150
|
*/
|
|
151
|
+
export function grantActorUid(entityType, relationships) {
|
|
152
|
+
if (entityType === "user") return relationships;
|
|
153
|
+
const uuid = getRequestIdentity()?.actor;
|
|
154
|
+
if (typeof uuid !== "string") return relationships;
|
|
155
|
+
validateUuid(uuid, "actor");
|
|
156
|
+
const base = relationships && typeof relationships === "object" && !Array.isArray(relationships)
|
|
157
|
+
? relationships
|
|
158
|
+
: {};
|
|
159
|
+
return {
|
|
160
|
+
...base,
|
|
161
|
+
uid: { data: { type: "user--user", id: uuid } },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read/write Backend adapter backed by Drupal core JSON:API. */
|
|
139
166
|
export class JsonApiBackend extends Backend {
|
|
140
167
|
/** @param {object} site Site config (must include `_name`). */
|
|
141
168
|
constructor(site) {
|
|
@@ -337,7 +364,8 @@ export class JsonApiBackend extends Backend {
|
|
|
337
364
|
async createEntity({ entityType, bundle, attributes = {}, relationships }) {
|
|
338
365
|
const buildPayload = (attrs) => {
|
|
339
366
|
const payload = { data: { type: `${entityType}--${bundle}`, attributes: attrs } };
|
|
340
|
-
|
|
367
|
+
const rels = grantActorUid(entityType, relationships);
|
|
368
|
+
if (rels) payload.data.relationships = rels;
|
|
341
369
|
return payload;
|
|
342
370
|
};
|
|
343
371
|
const data = await this.writeWithModerationFallback(this.resourcePath(entityType, bundle), "POST", buildPayload, attributes);
|
|
@@ -357,7 +385,8 @@ export class JsonApiBackend extends Backend {
|
|
|
357
385
|
validateUuid(id);
|
|
358
386
|
const buildPayload = (attrs) => {
|
|
359
387
|
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
360
|
-
|
|
388
|
+
const rels = grantActorUid(entityType, relationships);
|
|
389
|
+
if (rels) payload.data.relationships = rels;
|
|
361
390
|
return payload;
|
|
362
391
|
};
|
|
363
392
|
let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
package/src/lib/config.js
CHANGED
|
@@ -244,6 +244,70 @@ export function getInboundGrants() {
|
|
|
244
244
|
return entries.length ? Object.fromEntries(entries) : null;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Server-resolved inbound tenant grants keyed by OAuth client id.
|
|
249
|
+
* When present, the edge routes by tenant agent id, never by caller input.
|
|
250
|
+
* A missing or empty map means tenant is derived from site grants (compat).
|
|
251
|
+
* @returns {object|null}
|
|
252
|
+
*/
|
|
253
|
+
export function getInboundTenantGrants() {
|
|
254
|
+
const grants = loadConfig().auth?.tenantGrants;
|
|
255
|
+
if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
|
|
256
|
+
const entries = Object.entries(grants)
|
|
257
|
+
.filter(([clientId, tenants]) => !clientId.startsWith("_") && Array.isArray(tenants))
|
|
258
|
+
.map(([clientId, tenants]) => [
|
|
259
|
+
clientId,
|
|
260
|
+
[...new Set(
|
|
261
|
+
tenants.map((id) => String(id).trim()).filter((id) => id && !id.startsWith("_")),
|
|
262
|
+
)],
|
|
263
|
+
]);
|
|
264
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Server-resolved inbound actor map keyed by OAuth `sub` or client id.
|
|
269
|
+
* When present, write-like tools require a mapped Drupal user UUID.
|
|
270
|
+
* @returns {object|null}
|
|
271
|
+
*/
|
|
272
|
+
export function getInboundActors() {
|
|
273
|
+
const actors = loadConfig().auth?.actors;
|
|
274
|
+
if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
|
|
275
|
+
const entries = Object.entries(actors)
|
|
276
|
+
.map(([key, value]) => [key.trim(), value])
|
|
277
|
+
.filter(([key]) => key && !key.startsWith("_"));
|
|
278
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Server-resolved inbound policy map keyed by OAuth `sub` or client id.
|
|
283
|
+
* When present, non-diagnostic tools/call require a mapped SHA-256 digest.
|
|
284
|
+
* @returns {object|null}
|
|
285
|
+
*/
|
|
286
|
+
export function getInboundPolicies() {
|
|
287
|
+
const policies = loadConfig().auth?.policies;
|
|
288
|
+
if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
|
|
289
|
+
const entries = Object.entries(policies)
|
|
290
|
+
.map(([key, value]) => [key.trim(), value])
|
|
291
|
+
.filter(([key]) => key && !key.startsWith("_"));
|
|
292
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
293
|
+
}
|
|
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
|
+
|
|
247
311
|
// ---------------------------------------------------------------------------
|
|
248
312
|
// Auth headers — never logged, never exposed in tool responses
|
|
249
313
|
// ---------------------------------------------------------------------------
|
package/src/lib/http-auth.js
CHANGED
|
@@ -68,7 +68,7 @@ export function scopesFromClaim(scope) {
|
|
|
68
68
|
/**
|
|
69
69
|
* Freeze a request identity from validated claims. Caller headers are not an input.
|
|
70
70
|
* @param {object} claims
|
|
71
|
-
* @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string}>}
|
|
71
|
+
* @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string, actSub: ?string}>}
|
|
72
72
|
*/
|
|
73
73
|
export function buildIdentity(claims) {
|
|
74
74
|
const scopes = scopesFromClaim(claims.scope ?? claims.scp);
|
|
@@ -95,6 +95,10 @@ export function buildIdentity(claims) {
|
|
|
95
95
|
&& (claims.client_id === undefined || claims.client_id === null)
|
|
96
96
|
? null
|
|
97
97
|
: String(claims.azp ?? claims.client_id),
|
|
98
|
+
actSub: (claims.act && typeof claims.act === "object" && !Array.isArray(claims.act)
|
|
99
|
+
&& typeof claims.act.sub === "string" && claims.act.sub.trim())
|
|
100
|
+
? claims.act.sub.trim()
|
|
101
|
+
: null,
|
|
98
102
|
});
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -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
|
+
}
|