drupal-mcp-connector 2.8.0 → 2.10.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,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.10.0] - 2026-08-27
11
+
12
+ ### Security
13
+ - **Lab fan-down strips caller credential headers (#229).** The
14
+ outbound-relay lab harness framed the northbound request's headers down
15
+ the tenant tunnel verbatim, including `Authorization` — invisible in the
16
+ lab (the stub is in-process and the lab northbound carries no bearer),
17
+ but the wrong pattern for anyone wiring the README's optional live hop.
18
+ `Authorization`, `Cookie`, and `Proxy-Authorization` are now stripped,
19
+ with hop-by-hop headers, before framing; a test asserts marker
20
+ credentials never appear in any frame crossing the tunnel. The lab
21
+ README now states plainly that the lab northbound does not authenticate
22
+ the caller (the lab credential authenticates the agent channel) — the
23
+ product edge's northbound OAuth is DEV-294 work, not this harness.
24
+
25
+ ### Added
26
+ - **Deployment documentation for the relay edge and tenant-agent entry
27
+ points (#232).** `docs/deployment.md` gains the outbound-path section:
28
+ startup requirements, credential-header stripping, channel credentials,
29
+ and the issuer claim requirements.
30
+ - **Relay edge and tenant-agent entry points (#232).** `drupal-mcp-edge`
31
+ terminates northbound MCP on the inbound OAuth resource server — the only
32
+ authentication arm on this entry point; a missing issuer/audience is fatal
33
+ at any bind host, including loopback — and fans requests down an outbound
34
+ tenant channel. The edge requires a non-empty `auth.grants` table and an
35
+ agent channel credential store to start, resolves the authoritative target
36
+ per request before fan-down, strips caller credential and
37
+ identity-assertion headers before framing (the frame carries the validated
38
+ identity object only), and holds no site credentials — a catalog entry
39
+ carrying credential material refuses startup. `drupal-mcp-agent` dials out
40
+ (it never listens), authenticates the channel with its own issued,
41
+ revocable credential, and serves the real connector tool surface, so site
42
+ credentials exist only in the tenant process. Northbound is stateless MCP
43
+ 2026-07-28 with no session ids in either direction; revocation of both the
44
+ northbound principal and the agent channel is per-request with no grace
45
+ window. The frame codec lives at `src/lib/relay/frames.js` with hard size,
46
+ teardown, and timeout bounds. These are entry points and libraries only;
47
+ nothing here is a hosted service.
48
+ - **Lab-only outbound-relay harness (DEV-293).** Isolated under
49
+ `lab/outbound-relay/` with tests in `tests/lab/`. A tenant agent dials out
50
+ to a loopback relay; one MCP 2026-07-28 Streamable-HTTP request (stateless,
51
+ no `Mcp-Session-Id`) reaches an in-process stub private Drupal; reconnect
52
+ keeps the same tunnel identity; revocation is checked per request (next
53
+ request denied, no grace window). This is not a public surface, not a
54
+ hosted-service claim, and not DEV-294. Hosted MCP is unstarted.
55
+
56
+ ## [2.9.0] - 2026-08-26
57
+
58
+ ### Fixed
59
+ - **Adapter-contract execute trusts a fresh evaluation (#181).**
60
+ Manifest digests are always hashed (caller-supplied digests are ignored).
61
+ `execute` re-evaluates and will not honor a forged allow. Actor-bound
62
+ approvals cannot be consumed without that actor. Vendor keys are rejected
63
+ on `hints`. `propose` and `policyDigest` use the same relay hint keys as
64
+ evaluate. A required-evidence failure after a backend write rolls the
65
+ mutation back. `publish` with an id updates that entity instead of
66
+ creating a duplicate.
67
+
68
+ ### Added
69
+ - **Provider-neutral adapter contracts and a Drupal conformance kit (#181).**
70
+ Versioned evaluator, relay, approval, evidence-sink, and system-of-record
71
+ contracts live at `src/lib/contracts/` (contract 1.0). Typed decisions
72
+ (`deny` / `allow` / `allow_with_obligations` / `require_approval`), stable
73
+ reason codes, obligations, and execution receipts are verified at that
74
+ seam. Final target-side denial stays authoritative: an upstream allow
75
+ cannot widen local policy. Model and agent vendors are outside the
76
+ contract. The Drupal adapter is the only system-of-record implementation;
77
+ JSON:API and GraphQL remain transport adapters. The offline conformance
78
+ kit covers allowed and denied actions, hostile input, tenant escape,
79
+ required-evidence write failure, replay, and post-condition discrepancy.
80
+ See [docs/adapter-contracts.md](docs/adapter-contracts.md).
81
+
10
82
  ## [2.8.0] - 2026-08-25
11
83
 
12
84
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
- **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.4** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
14
14
  ---
15
15
 
@@ -308,6 +308,7 @@ an operator channel: keep the agent's credentials off it, and pin
308
308
  | [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
309
309
  | [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
310
310
  | [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
311
+ | [Adapter Contracts](docs/adapter-contracts.md) | Provider-neutral evaluator / relay / approval / evidence / SoR contracts and the Drupal conformance kit |
311
312
  | [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
312
313
  | [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
313
314
 
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drupal-mcp-agent — relay tenant agent entry point (#232).
4
+ *
5
+ * Dials OUT to the relay edge's agent channel and serves the real connector
6
+ * server (the full governed tool surface, dispatched through the same
7
+ * middleware as every other transport) over the framed tunnel. This process
8
+ * never listens: southbound site credentials exist only here, tenant-side.
9
+ * The channel is authenticated with the agent's own issued, revocable
10
+ * credential — never a northbound token, never a site credential.
11
+ *
12
+ * Environment variables:
13
+ * MCP_EDGE_HOST Relay edge host. Required.
14
+ * MCP_EDGE_AGENT_PORT Relay edge agent-channel port. Required.
15
+ * MCP_CHANNEL_TOKEN Issued channel credential (or MCP_CHANNEL_TOKEN_FILE).
16
+ * Required. The edge stores only its SHA-256 digest.
17
+ * MCP_EDGE_ALLOW_TCP "1" permits a plain TCP channel to loopback only
18
+ * (dev). The default channel is TLS.
19
+ *
20
+ * On a lost channel the process exits non-zero — fail loudly and let the
21
+ * supervisor restart it rather than idling disconnected.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { connect as netConnect } from "node:net";
26
+ import { connect as tlsConnect } from "node:tls";
27
+ import process from "node:process";
28
+ import { CLIENT_VERSION, listSiteNames, loadConfig } from "../src/lib/config.js";
29
+ import { callTool, listResolvableSiteConfigs } from "../src/lib/dispatch.js";
30
+ import { filterDiscoverableTools } from "../src/lib/governance.js";
31
+ import {
32
+ loadLocalSecrets,
33
+ secretLoadFatalMessage,
34
+ secretTableMismatchMessage,
35
+ } from "../src/lib/load-secrets.js";
36
+ import {
37
+ filterPromptsByPrincipal,
38
+ filterResourcesByPrincipal,
39
+ filterToolsByPrincipal,
40
+ getRequestIdentity,
41
+ visibleSiteTargets,
42
+ } from "../src/lib/principal.js";
43
+ import { createRelayAgent } from "../src/lib/relay/agent.js";
44
+ import { buildToolPrompts, getToolPromptMessages } from "../src/lib/tool-prompts.js";
45
+ import { allDefinitions, definitionsByName } from "../src/tools/index.js";
46
+
47
+ function fatal(message) {
48
+ console.error(`[drupal-mcp-agent] FATAL: ${message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const host = process.env.MCP_EDGE_HOST || "";
53
+ const port = Number(process.env.MCP_EDGE_AGENT_PORT || 0);
54
+ if (!host || !port) {
55
+ fatal("Set MCP_EDGE_HOST and MCP_EDGE_AGENT_PORT to the relay edge's agent channel.");
56
+ }
57
+
58
+ const tokenFile = process.env.MCP_CHANNEL_TOKEN_FILE || "";
59
+ const token = process.env.MCP_CHANNEL_TOKEN
60
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- credential path comes from the operator's environment, not user input
61
+ || (tokenFile ? readFileSync(tokenFile, "utf8").trim() : "");
62
+ if (!token) {
63
+ fatal(
64
+ "The tenant agent requires its issued channel credential: set "
65
+ + "MCP_CHANNEL_TOKEN or MCP_CHANNEL_TOKEN_FILE.",
66
+ );
67
+ }
68
+
69
+ // Apply config/secrets.map before any site resolution, exactly like the
70
+ // primary entry point: this process is where site credentials live.
71
+ const secretLoad = loadLocalSecrets();
72
+ const secretFatal = secretLoadFatalMessage(secretLoad);
73
+ if (secretFatal) fatal(secretFatal);
74
+ const secretMismatch = secretTableMismatchMessage(secretLoad);
75
+ if (secretMismatch) {
76
+ console.error(`[drupal-mcp-agent] WARNING: ${secretMismatch}`);
77
+ } else if (secretLoad.unset.length) {
78
+ console.error(
79
+ "[drupal-mcp-agent] WARNING: config.json names secret env vars that are unset: "
80
+ + `${secretLoad.unset.join(", ")}. Those sites will fail closed.`,
81
+ );
82
+ }
83
+
84
+ try {
85
+ loadConfig();
86
+ } catch (error) {
87
+ fatal(error instanceof Error ? error.message : "configuration load failed");
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Connector surface — the real governed tool surface. Workflow prompts and
92
+ // the templated resources stay on the primary entry point; the governed
93
+ // surface here is tools (full), the sites resource, and per-tool prompts.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const RESOURCES = [{
97
+ uri: "drupal://sites",
98
+ name: "Configured Drupal Sites",
99
+ description: "All named Drupal site profiles (no credentials).",
100
+ mimeType: "application/json",
101
+ }];
102
+
103
+ const TOOL_PROMPTS = buildToolPrompts(allDefinitions);
104
+
105
+ async function discoverableTools() {
106
+ const sites = listResolvableSiteConfigs();
107
+ const identity = getRequestIdentity();
108
+ const governed = await filterDiscoverableTools(allDefinitions, sites);
109
+ return filterToolsByPrincipal(governed, sites, identity);
110
+ }
111
+
112
+ const surface = {
113
+ serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
114
+ tools: {
115
+ definitions: allDefinitions,
116
+ list: discoverableTools,
117
+ call: callTool,
118
+ },
119
+ resources: {
120
+ definitions: RESOURCES,
121
+ list: async () => {
122
+ const sites = listResolvableSiteConfigs();
123
+ return filterResourcesByPrincipal(RESOURCES, getRequestIdentity(), sites);
124
+ },
125
+ read: async (uri) => {
126
+ if (uri === "drupal://sites") {
127
+ return visibleSiteTargets(
128
+ getRequestIdentity(),
129
+ listResolvableSiteConfigs(),
130
+ listSiteNames(),
131
+ );
132
+ }
133
+ throw new Error(`Unknown resource URI: ${uri}`);
134
+ },
135
+ },
136
+ prompts: {
137
+ definitions: TOOL_PROMPTS,
138
+ list: async () => {
139
+ const identity = getRequestIdentity();
140
+ const tools = await discoverableTools();
141
+ return filterPromptsByPrincipal(TOOL_PROMPTS, identity, tools);
142
+ },
143
+ get: (name, args) => getToolPromptMessages(name, args, definitionsByName),
144
+ },
145
+ };
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Channel — TLS by default; plain TCP only to loopback, by explicit opt-in.
149
+ // ---------------------------------------------------------------------------
150
+
151
+ const allowTcp = process.env.MCP_EDGE_ALLOW_TCP === "1";
152
+ const isLoopbackEdge = host === "127.0.0.1" || host === "::1" || host === "localhost";
153
+ let connectFn;
154
+ if (allowTcp) {
155
+ if (!isLoopbackEdge) {
156
+ fatal("MCP_EDGE_ALLOW_TCP permits a plain channel to loopback only. Use TLS.");
157
+ }
158
+ connectFn = netConnect;
159
+ } else {
160
+ connectFn = (options, onConnect) =>
161
+ tlsConnect({ ...options, servername: host }, onConnect);
162
+ }
163
+
164
+ const agent = createRelayAgent({
165
+ host,
166
+ port,
167
+ token,
168
+ surface,
169
+ connectFn,
170
+ onChannelClose: () => {
171
+ fatal("The channel to the relay edge was lost. Exiting for a supervised restart.");
172
+ },
173
+ });
174
+
175
+ let hello;
176
+ try {
177
+ hello = await agent.dial();
178
+ } catch (error) {
179
+ fatal(`Could not dial the relay edge at ${host}:${port}: `
180
+ + `${error instanceof Error ? error.message : "unknown error"}`);
181
+ }
182
+ if (!hello.ok) {
183
+ fatal(`The relay edge denied the channel credential (${hello.reason}).`);
184
+ }
185
+
186
+ console.error(
187
+ `[drupal-mcp-agent v${CLIENT_VERSION}] Outbound channel to ${host}:${port} `
188
+ + `established as "${hello.agent?.agentId ?? "unknown"}" · `
189
+ + `${allDefinitions.length} tools · ${RESOURCES.length} resources · `
190
+ + `${TOOL_PROMPTS.length} prompts`,
191
+ );
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drupal-mcp-edge — relay northbound edge entry point (#232).
4
+ *
5
+ * Terminates northbound MCP on the inbound OAuth resource server and fans
6
+ * requests down the outbound tenant-agent channel (see src/lib/relay/edge.js).
7
+ * There is no shared-bearer and no unauthenticated mode on this entry point:
8
+ * MCP_AUTH_TOKEN and MCP_ALLOW_UNAUTHENTICATED are ignored, and a missing
9
+ * issuer/audience is fatal at every bind host including loopback. The edge
10
+ * holds no site credentials; its site catalog is names and base URLs only.
11
+ *
12
+ * Environment variables:
13
+ * MCP_RESOURCE_ISSUER / MCP_RESOURCE_AUDIENCE / MCP_RESOURCE
14
+ * Inbound OAuth resource server (or config auth.issuer /
15
+ * auth.audience / auth.resource). Required.
16
+ * MCP_CHANNEL_CREDENTIALS_FILE
17
+ * Agent channel credential store (or config
18
+ * relay.channelCredentialsFile). Required. JSON:
19
+ * {"agents": {"<id>": {"tokenSha256": "<hex>"}}}
20
+ * MCP_EDGE_PORT Northbound port (default: MCP_PORT / config tls.port).
21
+ * MCP_EDGE_AGENT_PORT
22
+ * Agent channel port (default: northbound port + 1).
23
+ * MCP_BIND_HOST Northbound bind when TLS is present (default 0.0.0.0).
24
+ * MCP_EDGE_AGENT_BIND_HOST
25
+ * Agent channel bind (default: the northbound bind).
26
+ * TLS_CERT_PATH / TLS_KEY_PATH
27
+ * TLS material for both listeners.
28
+ * MCP_ALLOW_HTTP "1" permits plain loopback listeners (dev only).
29
+ * MCP_RATE_LIMIT / MCP_RATE_WINDOW_SEC
30
+ * Northbound /mcp rate limit (same defaults as the
31
+ * primary entry point).
32
+ *
33
+ * Config: auth.grants (client id -> [site names]) is mandatory; the edge
34
+ * refuses to start without it.
35
+ */
36
+
37
+ import { readFileSync } from "node:fs";
38
+ import process from "node:process";
39
+ import { getInboundGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
40
+ import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
41
+ import { createRateLimiter } from "../src/lib/rate-limit.js";
42
+ import {
43
+ createChannelCredentialStore,
44
+ startEdge,
45
+ } from "../src/lib/relay/edge.js";
46
+
47
+ function fatal(message) {
48
+ console.error(`[drupal-mcp-edge] FATAL: ${message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ let config;
53
+ try {
54
+ config = loadConfig();
55
+ } catch (error) {
56
+ fatal(error instanceof Error ? error.message : "configuration load failed");
57
+ }
58
+
59
+ const inboundCfg = resolveInboundAuthConfig(config);
60
+ if (!inboundCfg.issuer || !inboundCfg.audience) {
61
+ fatal(
62
+ "The relay edge requires an inbound OAuth resource server: set auth.issuer and "
63
+ + "auth.audience (or MCP_RESOURCE_ISSUER / MCP_RESOURCE_AUDIENCE). There is no "
64
+ + "shared-bearer or unauthenticated mode on this entry point, at any bind host "
65
+ + "including loopback.",
66
+ );
67
+ }
68
+
69
+ const grants = getInboundGrants();
70
+ if (!grants) {
71
+ fatal(
72
+ "The relay edge refuses to start without a non-empty auth.grants table "
73
+ + "(client id -> [site names]).",
74
+ );
75
+ }
76
+
77
+ const channelFile = process.env.MCP_CHANNEL_CREDENTIALS_FILE
78
+ || config.relay?.channelCredentialsFile
79
+ || "";
80
+ if (!channelFile) {
81
+ fatal(
82
+ "The relay edge requires an agent channel credential store: set "
83
+ + "MCP_CHANNEL_CREDENTIALS_FILE (or relay.channelCredentialsFile).",
84
+ );
85
+ }
86
+
87
+ // The catalog is passed as configured; startEdge refuses any entry carrying
88
+ // credential material, so a tenant config deployed to an edge host fails
89
+ // closed instead of quietly holding site secrets.
90
+ const sites = Object.entries(config.sites ?? {})
91
+ .map(([name, site]) => ({ _name: name, ...site }));
92
+
93
+ const tlsCfg = getTlsConfig();
94
+ let tls = null;
95
+ if (tlsCfg.certPath && tlsCfg.keyPath) {
96
+ tls = {
97
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- TLS cert/key path comes from operator-controlled config, not user input
98
+ cert: readFileSync(tlsCfg.certPath),
99
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- TLS cert/key path comes from operator-controlled config, not user input
100
+ key: readFileSync(tlsCfg.keyPath),
101
+ };
102
+ }
103
+ const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
104
+ const bindHost = tls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
105
+ const agentBindHost = process.env.MCP_EDGE_AGENT_BIND_HOST || bindHost;
106
+ const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
107
+
108
+ const port = Number(process.env.MCP_EDGE_PORT || tlsCfg.port);
109
+ const agentPort = Number(process.env.MCP_EDGE_AGENT_PORT || port + 1);
110
+
111
+ // Northbound rate limiting, same defaults as the primary entry point (#141).
112
+ const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
113
+ const rateLimitEnv = process.env.MCP_RATE_LIMIT;
114
+ const rateLimitDefault = (tls && !isLoopbackBind) ? 120 : 0;
115
+ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
116
+ ? rateLimitDefault
117
+ : Number(rateLimitEnv);
118
+
119
+ let edge;
120
+ try {
121
+ edge = await startEdge({
122
+ auth: inboundCfg,
123
+ grants,
124
+ sites,
125
+ defaultSite: config.defaultSite,
126
+ channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
127
+ bindHost,
128
+ agentBindHost,
129
+ port,
130
+ agentPort,
131
+ tls,
132
+ allowHttpLoopback: allowHttp,
133
+ rateLimiter: rateLimit > 0
134
+ ? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
135
+ : null,
136
+ });
137
+ } catch (error) {
138
+ fatal(error instanceof Error ? error.message : "edge startup failed");
139
+ }
140
+
141
+ console.error(
142
+ `[drupal-mcp-edge] Northbound ${edge.northboundUrl} · `
143
+ + `agent channel ${agentBindHost}:${edge.agentPort} · `
144
+ + `issuer ${inboundCfg.issuer}`,
145
+ );
146
+ if (rateLimit > 0) {
147
+ console.error(
148
+ `[drupal-mcp-edge] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`,
149
+ );
150
+ }
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.8.0",
3
+ "version": "2.10.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",
7
7
  "bin": {
8
8
  "drupal-mcp-connector": "src/index.js",
9
- "drupal-mcp-verify": "bin/drupal-mcp-verify.js"
9
+ "drupal-mcp-verify": "bin/drupal-mcp-verify.js",
10
+ "drupal-mcp-edge": "bin/drupal-mcp-edge.js",
11
+ "drupal-mcp-agent": "bin/drupal-mcp-agent.js"
10
12
  },
11
13
  "files": [
12
14
  "src/",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Approval-interface contract (#181).
3
+ *
4
+ * An approval binds to an action-manifest digest and a single actor. It is
5
+ * one-use. Replay, digest mismatch, or actor mismatch invalidate it.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { ContractError, REASON } from "./decisions.js";
10
+
11
+ /**
12
+ * @typedef {Object} ApprovalInterface
13
+ * @property {(manifest: object, actor?: string) => {approvalId: string, digest: string}} issue
14
+ * @property {(approvalId: string, digest: string, actor?: string) => {approvalId: string, digest: string}} consume
15
+ */
16
+
17
+ /**
18
+ * In-process one-use approval ledger.
19
+ * @returns {ApprovalInterface & {size: () => number}}
20
+ */
21
+ export function createMemoryApproval() {
22
+ const store = new Map();
23
+
24
+ return Object.freeze({
25
+ /**
26
+ * @param {object} manifest
27
+ * @param {string} [actor]
28
+ * @returns {{approvalId: string, digest: string}}
29
+ */
30
+ issue(manifest, actor) {
31
+ if (!manifest?.digest) {
32
+ throw new ContractError("Approval requires a manifest digest.", REASON.APPROVAL_REQUIRED);
33
+ }
34
+ const approvalId = randomUUID();
35
+ store.set(approvalId, {
36
+ digest: manifest.digest,
37
+ actor: actor ?? null,
38
+ used: false,
39
+ });
40
+ return { approvalId, digest: manifest.digest };
41
+ },
42
+
43
+ /**
44
+ * @param {string} approvalId
45
+ * @param {string} digest
46
+ * @param {string} [actor]
47
+ * @returns {{approvalId: string, digest: string}}
48
+ */
49
+ consume(approvalId, digest, actor) {
50
+ if (!approvalId) {
51
+ throw new ContractError("Approval required.", REASON.APPROVAL_REQUIRED);
52
+ }
53
+ const entry = store.get(approvalId);
54
+ if (!entry || entry.used) {
55
+ throw new ContractError("Approval already used or unknown.", REASON.REPLAY);
56
+ }
57
+ if (entry.digest !== digest) {
58
+ throw new ContractError("Approval digest mismatch.", REASON.REPLAY);
59
+ }
60
+ if (entry.actor && entry.actor !== actor) {
61
+ throw new ContractError("Approval actor mismatch.", REASON.REPLAY);
62
+ }
63
+ store.set(approvalId, { ...entry, used: true });
64
+ return { approvalId, digest };
65
+ },
66
+
67
+ /** @returns {number} */
68
+ size() {
69
+ return store.size;
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Typed decisions, stable reason codes, and the narrowing compose rule (#181).
3
+ *
4
+ * An upstream allow cannot widen a local or target deny. Obligations union
5
+ * only when both sides allow. Model and agent vendor keys are not part of
6
+ * any contract record.
7
+ */
8
+
9
+ /** Stable contract-level reason codes. Do not invent a parallel set. */
10
+ export const REASON = Object.freeze({
11
+ POLICY_DENIED: "policy_denied",
12
+ TARGET_DENIED: "target_denied",
13
+ TENANT_ESCAPE: "tenant_escape",
14
+ HOSTILE_INPUT: "hostile_input",
15
+ EVIDENCE_WRITE_FAILED: "evidence_write_failed",
16
+ REPLAY: "replay_detected",
17
+ POSTCONDITION: "postcondition_discrepancy",
18
+ APPROVAL_REQUIRED: "approval_required",
19
+ INCOMPATIBLE_CONTRACT: "incompatible_contract_version",
20
+ VENDOR_FIELD: "vendor_field_rejected",
21
+ });
22
+
23
+ /** Keys that name a model or agent vendor. They stay outside the contract. */
24
+ export const VENDOR_FIELD_NAMES = Object.freeze([
25
+ "model",
26
+ "modelVendor",
27
+ "agentVendor",
28
+ "agentFramework",
29
+ "llmProvider",
30
+ "openai",
31
+ "anthropic",
32
+ "vendor",
33
+ ]);
34
+
35
+ const VENDOR_FIELD_SET = new Set(VENDOR_FIELD_NAMES);
36
+
37
+ /**
38
+ * Decision / proposal error that carries a stable reason code.
39
+ */
40
+ export class ContractError extends Error {
41
+ /**
42
+ * @param {string} message Operator-facing description (no secrets).
43
+ * @param {string} reason Stable machine reason.
44
+ */
45
+ constructor(message, reason) {
46
+ super(message);
47
+ this.name = "ContractError";
48
+ this.reason = reason;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Reject records that carry a model or agent vendor key.
54
+ *
55
+ * @param {object} record Candidate identity, proposal, or manifest.
56
+ * @returns {void}
57
+ * @throws {ContractError}
58
+ */
59
+ export function assertNoVendorFields(record) {
60
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
61
+ return;
62
+ }
63
+ const keys = Object.keys(record);
64
+ for (const key of keys) {
65
+ if (VENDOR_FIELD_SET.has(key)) {
66
+ throw new ContractError(
67
+ "Model and agent vendor fields are outside the adapter contract.",
68
+ REASON.VENDOR_FIELD,
69
+ );
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {Array<{type: string, value?: string}>} left
76
+ * @param {Array<{type: string, value?: string}>} right
77
+ * @returns {Array<{type: string, value?: string}>}
78
+ */
79
+ export function unionObligations(left = [], right = []) {
80
+ const seen = new Set();
81
+ const out = [];
82
+ for (const item of [...left, ...right]) {
83
+ if (!item || typeof item.type !== "string") continue;
84
+ const key = `${item.type}:${item.value ?? ""}`;
85
+ if (seen.has(key)) continue;
86
+ seen.add(key);
87
+ out.push(Object.freeze({ type: item.type, value: item.value }));
88
+ }
89
+ return out;
90
+ }
91
+
92
+ const RESULT_RANK = new Map([
93
+ ["deny", 0],
94
+ ["require_approval", 1],
95
+ ["allow_with_obligations", 2],
96
+ ["allow", 3],
97
+ ]);
98
+
99
+ /**
100
+ * Compose an optional upstream decision with the local / target decision.
101
+ * Local deny is authoritative. Upstream allow never widens a local deny.
102
+ *
103
+ * @param {object|null|undefined} upstream Upstream evaluator decision.
104
+ * @param {object} local Local / target-side decision.
105
+ * @returns {object} Frozen composed decision.
106
+ */
107
+ export function composeDecisions(upstream, local) {
108
+ if (!upstream) return local;
109
+ if (local.result === "deny") return local;
110
+ if (upstream.result === "deny") return upstream;
111
+
112
+ const localRank = RESULT_RANK.get(local.result) ?? 0;
113
+ const upstreamRank = RESULT_RANK.get(upstream.result) ?? 0;
114
+ const narrower = localRank <= upstreamRank ? local : upstream;
115
+ const obligations = unionObligations(upstream.obligations, local.obligations);
116
+
117
+ if (narrower.result === "require_approval") {
118
+ return Object.freeze({
119
+ ...narrower,
120
+ result: "require_approval",
121
+ obligations: Object.freeze(obligations),
122
+ });
123
+ }
124
+
125
+ if (obligations.length > 0 || narrower.result === "allow_with_obligations") {
126
+ return Object.freeze({
127
+ ...local,
128
+ result: "allow_with_obligations",
129
+ reason: local.reason,
130
+ obligations: Object.freeze(obligations),
131
+ });
132
+ }
133
+
134
+ return Object.freeze({
135
+ ...local,
136
+ result: "allow",
137
+ obligations: Object.freeze([]),
138
+ });
139
+ }