@thotischner/observability-mcp 3.8.3 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/credentials.d.ts +19 -3
- package/dist/auth/credentials.js +17 -3
- package/dist/auth/credentials.test.js +11 -1
- package/dist/connectors/loader.d.ts +6 -0
- package/dist/connectors/loader.js +55 -6
- package/dist/connectors/loader.test.js +122 -1
- package/dist/context.d.ts +18 -0
- package/dist/context.js +19 -0
- package/dist/context.test.js +39 -1
- package/dist/index.js +14 -10
- package/package.json +1 -1
|
@@ -33,10 +33,21 @@
|
|
|
33
33
|
* # tools list = no restriction). Unlisted keys see
|
|
34
34
|
* # every registered tool — back-compat with the
|
|
35
35
|
* # pre-Products world. See docs/products.md.
|
|
36
|
+
* OMCP_KEY_TOOLS="agent=query_logs|get_service_health;ci=list_services"
|
|
37
|
+
* # optional per-key tool allow-list — same shape as
|
|
38
|
+
* # OMCP_KEY_SOURCES. When set, the credential's /mcp
|
|
39
|
+
* # tools/list (and dispatch) is scoped to exactly these
|
|
40
|
+
* # tool names. Composes with a bound Product by
|
|
41
|
+
* # INTERSECTION (most-restrictive wins). Unlisted keys
|
|
42
|
+
* # see every registered tool — back-compat. This is the
|
|
43
|
+
* # per-credential, source-symmetric counterpart to the
|
|
44
|
+
* # Product bundle: scope one API key to a few tools
|
|
45
|
+
* # without authoring a Product. See docs/products.md
|
|
46
|
+
* # ("Per-credential tool allow-list").
|
|
36
47
|
*
|
|
37
|
-
* Rich role-based access control (
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* Rich role-based access control (services/lookback/read-only, the full
|
|
49
|
+
* governance object) is intentionally NOT here — this is the authentication
|
|
50
|
+
* + identity + coarse source/tool-scoping primitive.
|
|
40
51
|
*/
|
|
41
52
|
export interface Credential {
|
|
42
53
|
name: string;
|
|
@@ -58,6 +69,11 @@ export interface Credential {
|
|
|
58
69
|
* is filtered to the Product's `tools` allow-list. Resolved against
|
|
59
70
|
* the credential's tenant so cross-tenant Products don't leak. */
|
|
60
71
|
productId?: string;
|
|
72
|
+
/** Per-credential tool allow-list (OMCP_KEY_TOOLS). When set, /mcp
|
|
73
|
+
* tools/list and dispatch are scoped to these tool names; composes
|
|
74
|
+
* with a bound Product by intersection (most-restrictive wins).
|
|
75
|
+
* Undefined → no per-credential tool restriction (back-compat). */
|
|
76
|
+
allowedTools?: string[];
|
|
61
77
|
}
|
|
62
78
|
/** Parse credentials from env. Returns an empty list when unconfigured. */
|
|
63
79
|
export declare function loadCredentials(env?: NodeJS.ProcessEnv): Credential[];
|
package/dist/auth/credentials.js
CHANGED
|
@@ -33,10 +33,21 @@
|
|
|
33
33
|
* # tools list = no restriction). Unlisted keys see
|
|
34
34
|
* # every registered tool — back-compat with the
|
|
35
35
|
* # pre-Products world. See docs/products.md.
|
|
36
|
+
* OMCP_KEY_TOOLS="agent=query_logs|get_service_health;ci=list_services"
|
|
37
|
+
* # optional per-key tool allow-list — same shape as
|
|
38
|
+
* # OMCP_KEY_SOURCES. When set, the credential's /mcp
|
|
39
|
+
* # tools/list (and dispatch) is scoped to exactly these
|
|
40
|
+
* # tool names. Composes with a bound Product by
|
|
41
|
+
* # INTERSECTION (most-restrictive wins). Unlisted keys
|
|
42
|
+
* # see every registered tool — back-compat. This is the
|
|
43
|
+
* # per-credential, source-symmetric counterpart to the
|
|
44
|
+
* # Product bundle: scope one API key to a few tools
|
|
45
|
+
* # without authoring a Product. See docs/products.md
|
|
46
|
+
* # ("Per-credential tool allow-list").
|
|
36
47
|
*
|
|
37
|
-
* Rich role-based access control (
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* Rich role-based access control (services/lookback/read-only, the full
|
|
49
|
+
* governance object) is intentionally NOT here — this is the authentication
|
|
50
|
+
* + identity + coarse source/tool-scoping primitive.
|
|
40
51
|
*/
|
|
41
52
|
import { parseKeyTenants } from "../tenancy/context.js";
|
|
42
53
|
function parseKeySources(raw) {
|
|
@@ -84,6 +95,8 @@ export function loadCredentials(env = process.env) {
|
|
|
84
95
|
const rawQueryNames = parseBypassSet(env.OMCP_KEY_RAW_QUERY);
|
|
85
96
|
const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
|
|
86
97
|
const keyProducts = parseKeyProducts(env.OMCP_KEY_PRODUCTS);
|
|
98
|
+
// OMCP_KEY_TOOLS shares the OMCP_KEY_SOURCES grammar (`name=a|b;name2=c`).
|
|
99
|
+
const keyTools = parseKeySources(env.OMCP_KEY_TOOLS);
|
|
87
100
|
const creds = [];
|
|
88
101
|
for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
89
102
|
const idx = part.indexOf(":");
|
|
@@ -99,6 +112,7 @@ export function loadCredentials(env = process.env) {
|
|
|
99
112
|
allowRawQuery: rawQueryNames.has(name) || undefined,
|
|
100
113
|
tenant: keyTenants.get(name) || undefined,
|
|
101
114
|
productId: keyProducts.get(name) || undefined,
|
|
115
|
+
allowedTools: keyTools.get(name),
|
|
102
116
|
});
|
|
103
117
|
}
|
|
104
118
|
return creds;
|
|
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
|
|
|
11
11
|
it("parses name:token and bare token", () => {
|
|
12
12
|
const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
|
|
13
13
|
assert.equal(creds.length, 2);
|
|
14
|
-
assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined });
|
|
14
|
+
assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined, allowedTools: undefined });
|
|
15
15
|
assert.equal(creds[1].name, "key");
|
|
16
16
|
assert.equal(creds[1].token, "tok_bare");
|
|
17
17
|
});
|
|
@@ -23,6 +23,16 @@ describe("single-tenant auth primitive", () => {
|
|
|
23
23
|
assert.deepEqual(creds[0].allowedSources, ["prom-prod", "loki-prod"]);
|
|
24
24
|
assert.deepEqual(creds[1].allowedSources, ["prom-staging"]);
|
|
25
25
|
});
|
|
26
|
+
it("parses per-key tool allow-list (OMCP_KEY_TOOLS)", () => {
|
|
27
|
+
const creds = loadCredentials({
|
|
28
|
+
OMCP_API_KEYS: "agent:tok1,ci:tok2,full:tok3",
|
|
29
|
+
OMCP_KEY_TOOLS: "agent=query_logs|get_service_health; ci=list_services",
|
|
30
|
+
});
|
|
31
|
+
assert.deepEqual(creds.find((c) => c.name === "agent")?.allowedTools, ["query_logs", "get_service_health"]);
|
|
32
|
+
assert.deepEqual(creds.find((c) => c.name === "ci")?.allowedTools, ["list_services"]);
|
|
33
|
+
// Unlisted key → undefined (no restriction, back-compat), not [].
|
|
34
|
+
assert.equal(creds.find((c) => c.name === "full")?.allowedTools, undefined);
|
|
35
|
+
});
|
|
26
36
|
it("parses OMCP_KEY_BYPASS_REDACTION → flags only the listed names", () => {
|
|
27
37
|
const creds = loadCredentials({
|
|
28
38
|
OMCP_API_KEYS: "agent:tok1,ci:tok2,unprivileged:tok3",
|
|
@@ -27,6 +27,7 @@ export declare class PluginLoader {
|
|
|
27
27
|
private verify;
|
|
28
28
|
private trustRootPath?;
|
|
29
29
|
private trustRoot?;
|
|
30
|
+
private requireSignature;
|
|
30
31
|
/** Optional HookRegistry — when set, the loader auto-registers
|
|
31
32
|
* every entry in `manifest.hooks[]` after the plugin loads, and
|
|
32
33
|
* unregisters them when a same-name plugin replaces it. Hooks
|
|
@@ -39,6 +40,7 @@ export declare class PluginLoader {
|
|
|
39
40
|
verify?: boolean;
|
|
40
41
|
trustRoot?: string;
|
|
41
42
|
hookRegistry?: HookRegistry;
|
|
43
|
+
requireSignature?: boolean;
|
|
42
44
|
});
|
|
43
45
|
load(): Promise<void>;
|
|
44
46
|
list(): LoadedConnector[];
|
|
@@ -49,6 +51,10 @@ export declare class PluginLoader {
|
|
|
49
51
|
create(name: string): ObservabilityConnector | undefined;
|
|
50
52
|
private loadBuiltins;
|
|
51
53
|
private loadFilesystem;
|
|
54
|
+
/** A filesystem plugin failed the verification gate. In strict mode
|
|
55
|
+
* (PLUGIN_REQUIRE_SIGNATURE) this throws to abort load(); otherwise it
|
|
56
|
+
* warns and the caller skips the plugin (fail-closed but non-fatal). */
|
|
57
|
+
private gateFailure;
|
|
52
58
|
private loadFilesystemPlugin;
|
|
53
59
|
private register;
|
|
54
60
|
}
|
|
@@ -31,6 +31,15 @@ export class PluginLoader {
|
|
|
31
31
|
verify;
|
|
32
32
|
trustRootPath;
|
|
33
33
|
trustRoot;
|
|
34
|
+
// Strict mode (PLUGIN_REQUIRE_SIGNATURE=true). Default OFF. When OFF, a
|
|
35
|
+
// filesystem plugin that fails the verification gate is silently SKIPPED
|
|
36
|
+
// (warn + continue) — the server still boots, just without that connector.
|
|
37
|
+
// When ON, a present-but-unverifiable plugin (missing manifest / missing
|
|
38
|
+
// or invalid signature / integrity mismatch), or a missing/unloadable trust
|
|
39
|
+
// root, is a HARD error that aborts load() — so an operator who demands
|
|
40
|
+
// signed connectors fails to start rather than silently running a reduced
|
|
41
|
+
// set. Implies verify (you cannot require signatures without verifying).
|
|
42
|
+
requireSignature;
|
|
34
43
|
/** Optional HookRegistry — when set, the loader auto-registers
|
|
35
44
|
* every entry in `manifest.hooks[]` after the plugin loads, and
|
|
36
45
|
* unregisters them when a same-name plugin replaces it. Hooks
|
|
@@ -50,11 +59,24 @@ export class PluginLoader {
|
|
|
50
59
|
this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
|
|
51
60
|
this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
|
|
52
61
|
this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
|
|
62
|
+
this.requireSignature =
|
|
63
|
+
opts.requireSignature ?? /^(1|true|yes|on)$/i.test(process.env.PLUGIN_REQUIRE_SIGNATURE ?? "");
|
|
64
|
+
// Requiring signatures implies verifying them — an operator who sets
|
|
65
|
+
// PLUGIN_REQUIRE_SIGNATURE=true but left VERIFY_PLUGINS=false meant the
|
|
66
|
+
// stricter posture; honour it rather than silently no-op.
|
|
67
|
+
if (this.requireSignature)
|
|
68
|
+
this.verify = true;
|
|
53
69
|
}
|
|
54
70
|
async load() {
|
|
55
71
|
this.loadBuiltins();
|
|
56
72
|
if (this.verify) {
|
|
57
73
|
if (!this.trustRootPath) {
|
|
74
|
+
// In strict mode this is a misconfiguration — the operator demanded
|
|
75
|
+
// signed connectors but gave no way to verify them. Fail hard rather
|
|
76
|
+
// than silently degrade to builtins-only.
|
|
77
|
+
if (this.requireSignature) {
|
|
78
|
+
throw new PluginVerificationError("PLUGIN_REQUIRE_SIGNATURE is on but PLUGIN_TRUST_ROOT is unset — cannot enforce signatures. Set a trust root or unset PLUGIN_REQUIRE_SIGNATURE.");
|
|
79
|
+
}
|
|
58
80
|
console.warn("VERIFY_PLUGINS is on but PLUGIN_TRUST_ROOT is unset — refusing to load any filesystem plugins (fail-closed). Builtins remain available.");
|
|
59
81
|
return;
|
|
60
82
|
}
|
|
@@ -63,6 +85,9 @@ export class PluginLoader {
|
|
|
63
85
|
console.log("Plugin verification enabled; trust root loaded from %s", sanitizeForLog(this.trustRootPath));
|
|
64
86
|
}
|
|
65
87
|
catch (err) {
|
|
88
|
+
if (this.requireSignature) {
|
|
89
|
+
throw new PluginVerificationError(`PLUGIN_REQUIRE_SIGNATURE is on but the trust root failed to load (${String(err)}) — cannot enforce signatures.`);
|
|
90
|
+
}
|
|
66
91
|
console.warn("VERIFY_PLUGINS is on but trust root failed to load (%s) — refusing to load any filesystem plugins (fail-closed). Builtins remain available.", sanitizeForLog(String(err)));
|
|
67
92
|
return;
|
|
68
93
|
}
|
|
@@ -161,10 +186,23 @@ export class PluginLoader {
|
|
|
161
186
|
await this.loadFilesystemPlugin(pluginRoot);
|
|
162
187
|
}
|
|
163
188
|
catch (err) {
|
|
189
|
+
// In strict mode a verification failure must abort load() rather than
|
|
190
|
+
// be swallowed as a per-plugin warning — propagate it.
|
|
191
|
+
if (this.requireSignature && err instanceof PluginVerificationError)
|
|
192
|
+
throw err;
|
|
164
193
|
console.warn("Failed to load plugin %s: %s", sanitizeForLog(entry), sanitizeForLog(String(err)));
|
|
165
194
|
}
|
|
166
195
|
}
|
|
167
196
|
}
|
|
197
|
+
/** A filesystem plugin failed the verification gate. In strict mode
|
|
198
|
+
* (PLUGIN_REQUIRE_SIGNATURE) this throws to abort load(); otherwise it
|
|
199
|
+
* warns and the caller skips the plugin (fail-closed but non-fatal). */
|
|
200
|
+
gateFailure(name, reason) {
|
|
201
|
+
if (this.requireSignature) {
|
|
202
|
+
throw new PluginVerificationError(`PLUGIN_REQUIRE_SIGNATURE: plugin ${name} ${reason} — refusing to start.`);
|
|
203
|
+
}
|
|
204
|
+
console.warn("VERIFY_PLUGINS: plugin %s %s — skipping (fail-closed)", sanitizeForLog(name), sanitizeForLog(reason));
|
|
205
|
+
}
|
|
168
206
|
async loadFilesystemPlugin(pluginRoot) {
|
|
169
207
|
const pkgPath = join(pluginRoot, "package.json");
|
|
170
208
|
if (!existsSync(pkgPath))
|
|
@@ -180,18 +218,29 @@ export class PluginLoader {
|
|
|
180
218
|
manifestPath = resolve(pluginRoot, marker.manifest);
|
|
181
219
|
if (existsSync(manifestPath)) {
|
|
182
220
|
manifestBytes = readFileSync(manifestPath);
|
|
183
|
-
|
|
221
|
+
// A present-but-malformed manifest (corrupt JSON / schema-invalid /
|
|
222
|
+
// name mismatch) is "present but cannot be verified" — under strict
|
|
223
|
+
// mode gateFailure() aborts startup, so the connector can't silently
|
|
224
|
+
// go missing; otherwise it warns + skips as before.
|
|
225
|
+
let raw;
|
|
226
|
+
try {
|
|
227
|
+
raw = JSON.parse(manifestBytes.toString("utf8"));
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
this.gateFailure(marker.name, `has unparseable manifest.json (${String(err)})`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
184
233
|
const parsed = manifestSchema.safeParse(raw);
|
|
185
234
|
if (!parsed.success) {
|
|
186
235
|
const issues = parsed.error.issues
|
|
187
236
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
188
237
|
.join("; ");
|
|
189
|
-
|
|
238
|
+
this.gateFailure(marker.name, `has invalid manifest.json — ${issues}`);
|
|
190
239
|
return;
|
|
191
240
|
}
|
|
192
241
|
manifest = parsed.data;
|
|
193
242
|
if (manifest.name !== marker.name) {
|
|
194
|
-
|
|
243
|
+
this.gateFailure(marker.name, `package.json marker name does not match manifest.json (${manifest.name})`);
|
|
195
244
|
return;
|
|
196
245
|
}
|
|
197
246
|
}
|
|
@@ -208,12 +257,12 @@ export class PluginLoader {
|
|
|
208
257
|
// against the trust root. Everything is local — airgapped-safe.
|
|
209
258
|
if (this.verify) {
|
|
210
259
|
if (!manifest || !manifestPath || !manifestBytes) {
|
|
211
|
-
|
|
260
|
+
this.gateFailure(marker.name, "has no manifest.json");
|
|
212
261
|
return;
|
|
213
262
|
}
|
|
214
263
|
const sigPath = manifestPath + ".sig";
|
|
215
264
|
if (!existsSync(sigPath)) {
|
|
216
|
-
|
|
265
|
+
this.gateFailure(marker.name, `missing manifest signature ${marker.manifest + ".sig"}`);
|
|
217
266
|
return;
|
|
218
267
|
}
|
|
219
268
|
try {
|
|
@@ -222,7 +271,7 @@ export class PluginLoader {
|
|
|
222
271
|
}
|
|
223
272
|
catch (err) {
|
|
224
273
|
const detail = err instanceof PluginVerificationError ? err.message : String(err);
|
|
225
|
-
|
|
274
|
+
this.gateFailure(marker.name, `failed verification (${detail})`);
|
|
226
275
|
return;
|
|
227
276
|
}
|
|
228
277
|
console.log("VERIFY_PLUGINS: plugin %s signature + integrity OK", sanitizeForLog(marker.name));
|
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { generateKeyPairSync, sign as cryptoSign } from "node:crypto";
|
|
6
7
|
import { PluginLoader } from "./loader.js";
|
|
8
|
+
import { PluginVerificationError, sha256Integrity } from "./verify.js";
|
|
7
9
|
function tmp() {
|
|
8
10
|
return mkdtempSync(join(tmpdir(), "loader-default-"));
|
|
9
11
|
}
|
|
12
|
+
const ENTRY_SRC = "export default () => ({});\n";
|
|
13
|
+
/** Write a public-key PEM trust root + return its path and the signing key. */
|
|
14
|
+
function makeTrustRoot() {
|
|
15
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "loader-trust-"));
|
|
17
|
+
const p = join(dir, "trust.pem");
|
|
18
|
+
writeFileSync(p, publicKey.export({ type: "spki", format: "pem" }));
|
|
19
|
+
return { path: p, privateKey };
|
|
20
|
+
}
|
|
21
|
+
/** Build a filesystem connector plugin dir under `pluginsDir`.
|
|
22
|
+
* opts.manifest=false → no manifest.json; opts.sign omitted → no .sig;
|
|
23
|
+
* opts.sign=key → a valid detached signature over the manifest bytes. */
|
|
24
|
+
function makePlugin(pluginsDir, name, opts = {}) {
|
|
25
|
+
const root = join(pluginsDir, name);
|
|
26
|
+
mkdirSync(root, { recursive: true });
|
|
27
|
+
writeFileSync(join(root, "index.js"), ENTRY_SRC);
|
|
28
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ main: "index.js", observabilityMcp: { kind: "connector", name, manifest: "manifest.json" } }));
|
|
29
|
+
if (opts.manifest === false)
|
|
30
|
+
return;
|
|
31
|
+
const manifestPath = join(root, "manifest.json");
|
|
32
|
+
const manifestBytes = opts.rawManifest !== undefined
|
|
33
|
+
? Buffer.from(opts.rawManifest)
|
|
34
|
+
: Buffer.from(JSON.stringify({
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
name,
|
|
37
|
+
displayName: name,
|
|
38
|
+
version: "1.0.0",
|
|
39
|
+
description: `${name} test connector`,
|
|
40
|
+
signalTypes: ["metrics"],
|
|
41
|
+
integrity: opts.integrity ?? sha256Integrity(Buffer.from(ENTRY_SRC)),
|
|
42
|
+
}));
|
|
43
|
+
writeFileSync(manifestPath, manifestBytes);
|
|
44
|
+
if (opts.sign)
|
|
45
|
+
writeFileSync(manifestPath + ".sig", cryptoSign(null, manifestBytes, opts.sign));
|
|
46
|
+
}
|
|
10
47
|
function withEnv(overrides, fn) {
|
|
11
48
|
const saved = {};
|
|
12
49
|
for (const k of Object.keys(overrides)) {
|
|
@@ -87,3 +124,87 @@ test("PluginLoader.load(): builtins carry manifest metadata (description shows i
|
|
|
87
124
|
assert.equal(c.manifest.name, name);
|
|
88
125
|
}
|
|
89
126
|
});
|
|
127
|
+
// --- PLUGIN_REQUIRE_SIGNATURE (strict load-time enforcement) ---
|
|
128
|
+
test("PluginLoader: PLUGIN_REQUIRE_SIGNATURE=true sets requireSignature and forces verify on", () => {
|
|
129
|
+
for (const v of ["true", "1", "yes", "On"]) {
|
|
130
|
+
// Even with VERIFY_PLUGINS=false, requiring signatures implies verifying.
|
|
131
|
+
withEnv({ PLUGIN_REQUIRE_SIGNATURE: v, VERIFY_PLUGINS: "false" }, () => {
|
|
132
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
133
|
+
assert.equal(loader["requireSignature"], true, `value ${v} should enable strict mode`);
|
|
134
|
+
assert.equal(loader["verify"], true, "requireSignature implies verify");
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
withEnv({ PLUGIN_REQUIRE_SIGNATURE: undefined }, () => {
|
|
138
|
+
assert.equal(new PluginLoader({ pluginsDir: tmp() })["requireSignature"], false, "default OFF");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
test("strict mode: a plugin with no manifest ABORTS load() (hard fail, not skip)", async () => {
|
|
142
|
+
const dir = tmp();
|
|
143
|
+
makePlugin(dir, "unsigned-conn", { manifest: false });
|
|
144
|
+
const trust = makeTrustRoot();
|
|
145
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
146
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
147
|
+
});
|
|
148
|
+
test("strict mode: a plugin with manifest but missing .sig ABORTS load()", async () => {
|
|
149
|
+
const dir = tmp();
|
|
150
|
+
makePlugin(dir, "nosig-conn", { /* manifest yes, sign no */});
|
|
151
|
+
const trust = makeTrustRoot();
|
|
152
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
153
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
154
|
+
});
|
|
155
|
+
test("strict mode: missing trust root ABORTS load() (misconfiguration)", async () => {
|
|
156
|
+
const loader = new PluginLoader({ pluginsDir: tmp(), verify: true, trustRoot: undefined, requireSignature: true });
|
|
157
|
+
await assert.rejects(() => loader.load(), /PLUGIN_TRUST_ROOT is unset/);
|
|
158
|
+
});
|
|
159
|
+
test("NON-strict (default): the same unverifiable plugin is skipped, builtins still load", async () => {
|
|
160
|
+
const dir = tmp();
|
|
161
|
+
makePlugin(dir, "unsigned-conn", { manifest: false });
|
|
162
|
+
const trust = makeTrustRoot();
|
|
163
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path /* requireSignature default false */ });
|
|
164
|
+
await loader.load(); // must NOT throw
|
|
165
|
+
const names = loader.supportedTypes();
|
|
166
|
+
assert.ok(names.includes("prometheus"), "builtins still load");
|
|
167
|
+
assert.ok(!names.includes("unsigned-conn"), "unverifiable plugin skipped, not registered");
|
|
168
|
+
});
|
|
169
|
+
test("strict mode: a correctly-signed plugin loads without error", async () => {
|
|
170
|
+
const dir = tmp();
|
|
171
|
+
const trust = makeTrustRoot();
|
|
172
|
+
makePlugin(dir, "good-conn", { sign: trust.privateKey });
|
|
173
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
174
|
+
await loader.load(); // valid signature + integrity → no throw
|
|
175
|
+
assert.ok(loader.supportedTypes().includes("good-conn"), "signed plugin registered under strict mode");
|
|
176
|
+
});
|
|
177
|
+
// A present-but-malformed manifest is "present but cannot be verified" — strict
|
|
178
|
+
// mode must hard-fail, not silently drop the connector.
|
|
179
|
+
test("strict mode: unparseable manifest.json ABORTS load()", async () => {
|
|
180
|
+
const dir = tmp();
|
|
181
|
+
makePlugin(dir, "corrupt-conn", { rawManifest: "{ this is not valid json" });
|
|
182
|
+
const trust = makeTrustRoot();
|
|
183
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
184
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
185
|
+
});
|
|
186
|
+
test("strict mode: schema-invalid manifest.json ABORTS load()", async () => {
|
|
187
|
+
const dir = tmp();
|
|
188
|
+
// Missing required fields (displayName/version/signalTypes/...).
|
|
189
|
+
makePlugin(dir, "badschema-conn", { rawManifest: JSON.stringify({ schemaVersion: 1, name: "badschema-conn" }) });
|
|
190
|
+
const trust = makeTrustRoot();
|
|
191
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
192
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
193
|
+
});
|
|
194
|
+
test("strict mode: integrity mismatch ABORTS load() (signed manifest, wrong digest)", async () => {
|
|
195
|
+
const dir = tmp();
|
|
196
|
+
const trust = makeTrustRoot();
|
|
197
|
+
// Validly signed manifest, but its integrity does not match index.js.
|
|
198
|
+
makePlugin(dir, "tampered-conn", { sign: trust.privateKey, integrity: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" });
|
|
199
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
200
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
201
|
+
});
|
|
202
|
+
test("NON-strict: a malformed manifest is still skipped (not fatal), builtins load", async () => {
|
|
203
|
+
const dir = tmp();
|
|
204
|
+
makePlugin(dir, "corrupt-conn", { rawManifest: "{ nope" });
|
|
205
|
+
const trust = makeTrustRoot();
|
|
206
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path /* non-strict */ });
|
|
207
|
+
await loader.load(); // must NOT throw
|
|
208
|
+
assert.ok(loader.supportedTypes().includes("prometheus"), "builtins still load");
|
|
209
|
+
assert.ok(!loader.supportedTypes().includes("corrupt-conn"), "malformed plugin skipped");
|
|
210
|
+
});
|
package/dist/context.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface RequestContext {
|
|
|
39
39
|
* Anonymous + Product-less credentials leave this unset and see
|
|
40
40
|
* every registered tool. */
|
|
41
41
|
allowedTools?: string[];
|
|
42
|
+
/** Per-credential tool allow-list (OMCP_KEY_TOOLS) — a SEPARATE axis from
|
|
43
|
+
* the Product `allowedTools`. The registration gate requires a tool to
|
|
44
|
+
* pass BOTH (each via allowsTool, undefined = no restriction), so the two
|
|
45
|
+
* compose by intersection without overloading the empty-list semantics.
|
|
46
|
+
* Unset for anonymous + unscoped credentials. */
|
|
47
|
+
credentialTools?: string[];
|
|
42
48
|
/** Correlates all tool calls within one transport request/session. */
|
|
43
49
|
correlationId: string;
|
|
44
50
|
}
|
|
@@ -59,6 +65,7 @@ export declare function principalContext(principalId: string, allowedSources?: s
|
|
|
59
65
|
allowRawQuery?: boolean;
|
|
60
66
|
tenant?: string;
|
|
61
67
|
allowedTools?: string[];
|
|
68
|
+
credentialTools?: string[];
|
|
62
69
|
}): RequestContext;
|
|
63
70
|
/** Context for an authenticated management-plane (browser / OIDC /
|
|
64
71
|
* basic-auth) request. The session-derived tenant flows into tool
|
|
@@ -85,3 +92,14 @@ export declare function sessionContext(session: {
|
|
|
85
92
|
* Tool names are compared case-sensitively; the MCP spec is
|
|
86
93
|
* case-sensitive on `name`. */
|
|
87
94
|
export declare function allowsTool(allowedTools: string[] | undefined, toolName: string): boolean;
|
|
95
|
+
/** Combine two tool allow-lists into the effective (most-restrictive) one.
|
|
96
|
+
* Both lists NARROW access, so the result is their intersection:
|
|
97
|
+
* - either side undefined → the other side wins (an absent list means
|
|
98
|
+
* "no restriction", so it can't tighten the other).
|
|
99
|
+
* - both set → only tools present in BOTH survive (so a per-credential
|
|
100
|
+
* OMCP_KEY_TOOLS list and a bound Product's `tools` list compose
|
|
101
|
+
* without one silently widening the other; an empty intersection
|
|
102
|
+
* means the credential can call nothing through that binding).
|
|
103
|
+
* Used to fold the per-credential allow-list together with the Product
|
|
104
|
+
* allow-list at request entry. */
|
|
105
|
+
export declare function intersectAllowed(a: string[] | undefined, b: string[] | undefined): string[] | undefined;
|
package/dist/context.js
CHANGED
|
@@ -27,6 +27,7 @@ export function principalContext(principalId, allowedSources, opts = {}) {
|
|
|
27
27
|
allowRawQuery: opts.allowRawQuery || undefined,
|
|
28
28
|
tenant: normaliseTenant(opts.tenant),
|
|
29
29
|
allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
|
|
30
|
+
credentialTools: opts.credentialTools && opts.credentialTools.length > 0 ? opts.credentialTools : undefined,
|
|
30
31
|
correlationId: randomUUID(),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -64,3 +65,21 @@ export function allowsTool(allowedTools, toolName) {
|
|
|
64
65
|
return true;
|
|
65
66
|
return allowedTools.includes(toolName);
|
|
66
67
|
}
|
|
68
|
+
/** Combine two tool allow-lists into the effective (most-restrictive) one.
|
|
69
|
+
* Both lists NARROW access, so the result is their intersection:
|
|
70
|
+
* - either side undefined → the other side wins (an absent list means
|
|
71
|
+
* "no restriction", so it can't tighten the other).
|
|
72
|
+
* - both set → only tools present in BOTH survive (so a per-credential
|
|
73
|
+
* OMCP_KEY_TOOLS list and a bound Product's `tools` list compose
|
|
74
|
+
* without one silently widening the other; an empty intersection
|
|
75
|
+
* means the credential can call nothing through that binding).
|
|
76
|
+
* Used to fold the per-credential allow-list together with the Product
|
|
77
|
+
* allow-list at request entry. */
|
|
78
|
+
export function intersectAllowed(a, b) {
|
|
79
|
+
if (!a)
|
|
80
|
+
return b;
|
|
81
|
+
if (!b)
|
|
82
|
+
return a;
|
|
83
|
+
const bSet = new Set(b);
|
|
84
|
+
return a.filter((t) => bSet.has(t));
|
|
85
|
+
}
|
package/dist/context.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { allowsTool, defaultContext, principalContext } from "./context.js";
|
|
3
|
+
import { allowsTool, intersectAllowed, defaultContext, principalContext } from "./context.js";
|
|
4
4
|
test("allowsTool — undefined allow-list = no Product binding = every tool allowed", () => {
|
|
5
5
|
assert.equal(allowsTool(undefined, "list_sources"), true);
|
|
6
6
|
assert.equal(allowsTool(undefined, "query_logs"), true);
|
|
@@ -15,6 +15,44 @@ test("allowsTool — non-empty allow-list gates by exact match", () => {
|
|
|
15
15
|
assert.equal(allowsTool(allow, "query_logs"), false);
|
|
16
16
|
assert.equal(allowsTool(allow, "get_topology"), false);
|
|
17
17
|
});
|
|
18
|
+
// intersectAllowed folds a per-credential list (OMCP_KEY_TOOLS) with a Product
|
|
19
|
+
// list — most-restrictive wins.
|
|
20
|
+
test("intersectAllowed — either side undefined returns the other (no widening)", () => {
|
|
21
|
+
assert.equal(intersectAllowed(undefined, undefined), undefined);
|
|
22
|
+
assert.deepEqual(intersectAllowed(["a", "b"], undefined), ["a", "b"]);
|
|
23
|
+
assert.deepEqual(intersectAllowed(undefined, ["a", "b"]), ["a", "b"]);
|
|
24
|
+
});
|
|
25
|
+
test("intersectAllowed — both set → intersection only", () => {
|
|
26
|
+
assert.deepEqual(intersectAllowed(["query_logs", "list_services"], ["query_logs", "get_topology"]), ["query_logs"]);
|
|
27
|
+
// Disjoint → empty list: the credential can call nothing through that binding.
|
|
28
|
+
assert.deepEqual(intersectAllowed(["query_logs"], ["get_topology"]), []);
|
|
29
|
+
// Order follows the first (credential) list.
|
|
30
|
+
assert.deepEqual(intersectAllowed(["b", "a"], ["a", "b"]), ["b", "a"]);
|
|
31
|
+
});
|
|
32
|
+
// The registration gate ANDs two independent allowsTool axes (Product +
|
|
33
|
+
// per-credential OMCP_KEY_TOOLS). This is what makes disjoint lists deny
|
|
34
|
+
// everything WITHOUT routing through an overloaded empty intersection — an
|
|
35
|
+
// empty `[]` would be read by allowsTool as "allow all", which is why the two
|
|
36
|
+
// axes are kept separate rather than pre-intersected into one list.
|
|
37
|
+
function passesGate(productTools, credTools, name) {
|
|
38
|
+
return allowsTool(productTools, name) && allowsTool(credTools, name);
|
|
39
|
+
}
|
|
40
|
+
test("two-axis gate — disjoint Product and credential lists deny every tool", () => {
|
|
41
|
+
// Product allows get_topology; credential allows only query_logs → nothing passes.
|
|
42
|
+
assert.equal(passesGate(["get_topology"], ["query_logs"], "query_logs"), false);
|
|
43
|
+
assert.equal(passesGate(["get_topology"], ["query_logs"], "get_topology"), false);
|
|
44
|
+
});
|
|
45
|
+
test("two-axis gate — credential list narrows within an unrestricted Product axis", () => {
|
|
46
|
+
assert.equal(passesGate(undefined, ["query_logs"], "query_logs"), true);
|
|
47
|
+
assert.equal(passesGate(undefined, ["query_logs"], "get_topology"), false);
|
|
48
|
+
});
|
|
49
|
+
test("two-axis gate — overlapping lists allow only the overlap", () => {
|
|
50
|
+
const product = ["query_logs", "get_topology", "list_services"];
|
|
51
|
+
const cred = ["query_logs", "list_services"];
|
|
52
|
+
assert.equal(passesGate(product, cred, "query_logs"), true);
|
|
53
|
+
assert.equal(passesGate(product, cred, "list_services"), true);
|
|
54
|
+
assert.equal(passesGate(product, cred, "get_topology"), false); // in Product, not in credential
|
|
55
|
+
});
|
|
18
56
|
test("allowsTool — case-sensitive (matches MCP spec)", () => {
|
|
19
57
|
const allow = ["list_sources"];
|
|
20
58
|
assert.equal(allowsTool(allow, "List_Sources"), false);
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
|
|
10
10
|
import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
|
|
11
11
|
import { isTopologyProvider } from "./connectors/interface.js";
|
|
12
|
-
import { defaultContext, principalContext, sessionContext, allowsTool } from "./context.js";
|
|
12
|
+
import { defaultContext, principalContext, sessionContext, allowsTool, intersectAllowed } from "./context.js";
|
|
13
13
|
import { parseKeyTenants, isMultiTenantConfigured } from "./tenancy/context.js";
|
|
14
14
|
import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, inspectEnforceEntitled, featureEntitled, entitledFeatures, } from "./enterprise-gate.js";
|
|
15
15
|
import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./auth/credentials.js";
|
|
@@ -382,8 +382,13 @@ async function main() {
|
|
|
382
382
|
// reaches the caller. When no hooks are registered (the default in
|
|
383
383
|
// the OSS demo) the wrapper is a thin pass-through.
|
|
384
384
|
const registerTool = ((name, ...rest) => {
|
|
385
|
-
|
|
385
|
+
// Two independent allow-list axes must BOTH pass: the Product binding
|
|
386
|
+
// (ctx.allowedTools, OMCP_KEY_PRODUCTS) and the per-credential list
|
|
387
|
+
// (ctx.credentialTools, OMCP_KEY_TOOLS). Either undefined = no restriction
|
|
388
|
+
// on that axis; disjoint non-empty lists therefore deny every tool.
|
|
389
|
+
if (!allowsTool(ctx.allowedTools, name) || !allowsTool(ctx.credentialTools, name)) {
|
|
386
390
|
return undefined;
|
|
391
|
+
}
|
|
387
392
|
if (rest.length > 0 && typeof rest[rest.length - 1] === "function") {
|
|
388
393
|
const originalHandler = rest[rest.length - 1];
|
|
389
394
|
const wrappedHandler = wrapToolHandler(hookRegistry, { principal: ctx.principalId, tenant: ctx.tenant || "default", target: name }, originalHandler);
|
|
@@ -1849,6 +1854,7 @@ async function main() {
|
|
|
1849
1854
|
productId: c.productId,
|
|
1850
1855
|
bypassRedaction: !!c.bypassRedaction,
|
|
1851
1856
|
allowedSources: c.allowedSources,
|
|
1857
|
+
allowedTools: c.allowedTools,
|
|
1852
1858
|
});
|
|
1853
1859
|
}
|
|
1854
1860
|
// OIDC groups → role mappings.
|
|
@@ -3670,6 +3676,11 @@ async function main() {
|
|
|
3670
3676
|
allowRawQuery: cred.allowRawQuery,
|
|
3671
3677
|
tenant: cred.tenant,
|
|
3672
3678
|
allowedTools,
|
|
3679
|
+
// Per-credential tool allow-list (OMCP_KEY_TOOLS) — a SEPARATE axis from
|
|
3680
|
+
// the Product one. The registration gate requires a tool to pass BOTH,
|
|
3681
|
+
// so disjoint lists deny everything without overloading the empty-list
|
|
3682
|
+
// "allow all" semantics allowsTool uses for the Product axis.
|
|
3683
|
+
credentialTools: cred.allowedTools,
|
|
3673
3684
|
});
|
|
3674
3685
|
}
|
|
3675
3686
|
app.post("/mcp", async (req, res) => {
|
|
@@ -3741,14 +3752,6 @@ async function main() {
|
|
|
3741
3752
|
// registerTool gate, so the surface a /mcp/v/<slug> client sees is
|
|
3742
3753
|
// strictly product.tools (intersected with any pre-existing
|
|
3743
3754
|
// allowedTools the credential already carries).
|
|
3744
|
-
function intersectAllowed(a, b) {
|
|
3745
|
-
if (!a)
|
|
3746
|
-
return b;
|
|
3747
|
-
if (!b)
|
|
3748
|
-
return a;
|
|
3749
|
-
const bSet = new Set(b);
|
|
3750
|
-
return a.filter((t) => bSet.has(t));
|
|
3751
|
-
}
|
|
3752
3755
|
async function resolveVirtualProduct(req, res, baseCtx) {
|
|
3753
3756
|
const slug = req.params.slug;
|
|
3754
3757
|
if (!slug || typeof slug !== "string") {
|
|
@@ -3911,6 +3914,7 @@ async function main() {
|
|
|
3911
3914
|
allowRawQuery: cred.allowRawQuery,
|
|
3912
3915
|
tenant: cred.tenant,
|
|
3913
3916
|
allowedTools,
|
|
3917
|
+
credentialTools: cred.allowedTools,
|
|
3914
3918
|
}),
|
|
3915
3919
|
selectedSubprotocol,
|
|
3916
3920
|
};
|
package/package.json
CHANGED