@thotischner/observability-mcp 3.7.0 → 3.8.1

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.
Files changed (53) hide show
  1. package/dist/auth/policy/loader.js +1 -1
  2. package/dist/auth/rbac.d.ts +1 -1
  3. package/dist/auth/rbac.js +3 -1
  4. package/dist/auth/rbac.test.js +5 -3
  5. package/dist/conformance/inspect-e2e.test.d.ts +1 -0
  6. package/dist/conformance/inspect-e2e.test.js +104 -0
  7. package/dist/connectors/loader.js +30 -0
  8. package/dist/connectors/loader.test.js +11 -0
  9. package/dist/enterprise-gate.d.ts +28 -0
  10. package/dist/enterprise-gate.js +51 -0
  11. package/dist/enterprise-gate.test.js +21 -1
  12. package/dist/index.js +285 -6
  13. package/dist/inspect/enforcer.d.ts +19 -0
  14. package/dist/inspect/enforcer.js +69 -0
  15. package/dist/inspect/enforcer.test.d.ts +1 -0
  16. package/dist/inspect/enforcer.test.js +76 -0
  17. package/dist/inspect/graph.d.ts +33 -0
  18. package/dist/inspect/graph.js +0 -0
  19. package/dist/inspect/graph.test.d.ts +1 -0
  20. package/dist/inspect/graph.test.js +74 -0
  21. package/dist/inspect/index.d.ts +8 -0
  22. package/dist/inspect/index.js +13 -0
  23. package/dist/inspect/mode.d.ts +20 -0
  24. package/dist/inspect/mode.js +57 -0
  25. package/dist/inspect/mode.test.d.ts +1 -0
  26. package/dist/inspect/mode.test.js +53 -0
  27. package/dist/inspect/profile-store.d.ts +42 -0
  28. package/dist/inspect/profile-store.js +139 -0
  29. package/dist/inspect/profile-store.test.d.ts +1 -0
  30. package/dist/inspect/profile-store.test.js +82 -0
  31. package/dist/inspect/profile.d.ts +51 -0
  32. package/dist/inspect/profile.js +111 -0
  33. package/dist/inspect/profile.test.d.ts +1 -0
  34. package/dist/inspect/profile.test.js +96 -0
  35. package/dist/inspect/recorder.d.ts +42 -0
  36. package/dist/inspect/recorder.js +72 -0
  37. package/dist/inspect/recorder.test.d.ts +1 -0
  38. package/dist/inspect/recorder.test.js +112 -0
  39. package/dist/inspect/signature.d.ts +32 -0
  40. package/dist/inspect/signature.js +200 -0
  41. package/dist/inspect/signature.test.d.ts +1 -0
  42. package/dist/inspect/signature.test.js +136 -0
  43. package/dist/inspect/store.d.ts +62 -0
  44. package/dist/inspect/store.js +76 -0
  45. package/dist/inspect/store.test.d.ts +1 -0
  46. package/dist/inspect/store.test.js +78 -0
  47. package/dist/metrics/self.d.ts +3 -0
  48. package/dist/metrics/self.js +19 -0
  49. package/dist/tenancy/context.d.ts +7 -0
  50. package/dist/tenancy/context.js +15 -0
  51. package/dist/tenancy/context.test.js +18 -1
  52. package/dist/ui/index.html +742 -0
  53. package/package.json +7 -3
package/dist/index.js CHANGED
@@ -10,8 +10,8 @@ import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } f
10
10
  import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
11
11
  import { isTopologyProvider } from "./connectors/interface.js";
12
12
  import { defaultContext, principalContext, sessionContext, allowsTool } from "./context.js";
13
- import { parseKeyTenants } from "./tenancy/context.js";
14
- import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, } from "./enterprise-gate.js";
13
+ import { parseKeyTenants, isMultiTenantConfigured } from "./tenancy/context.js";
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";
16
16
  import { issueSession, setCookieHeader, clearCookieHeader, generateSecret, } from "./auth/session.js";
17
17
  import { readUsersFile, writeUsersFile, authenticate, } from "./auth/local-users.js";
@@ -45,10 +45,11 @@ import { getPluginLoader } from "./connectors/loader.js";
45
45
  import { resolveHubCatalogUrl, describeInstalled, mergeCatalog, fetchHubCatalog, } from "./connectors/hub.js";
46
46
  import { isValidConnectorName, installTarball } from "./connectors/install.js";
47
47
  import { PluginVerificationError } from "./connectors/verify.js";
48
- import { selfRegistry, withToolMetrics, apiRequests, mcpActiveSessions, auditDlqDepth } from "./metrics/self.js";
48
+ import { selfRegistry, withToolMetrics, apiRequests, mcpActiveSessions, auditDlqDepth, recordInspectEvent } from "./metrics/self.js";
49
49
  import { initOtel } from "./observability/otel.js";
50
50
  import { WebSocketServerTransport } from "./transport/websocket.js";
51
51
  import { HookRegistry } from "./sdk/hooks.js";
52
+ import { InspectStore, ModeController, bootMode, createInspectRecorder, createInspectEnforcer, buildFlowGraph, durationToSeconds, ProfileStore } from "./inspect/index.js";
52
53
  import { wrapToolHandler, wrapResourceHandler, wrapPromptHandler } from "./sdk/hook-wrappers.js";
53
54
  import { UpstreamClient } from "./federation/upstream.js";
54
55
  import { FederationRegistry, parseFederationEnv } from "./federation/registry.js";
@@ -864,6 +865,9 @@ async function main() {
864
865
  let usersStore = null;
865
866
  let secretEphemeral = false;
866
867
  let oidcRuntime;
868
+ // Captured so the multi-tenancy entitlement gate below can tell whether
869
+ // OIDC is configured to read a tenant claim (non-empty → tenant'd logins).
870
+ let oidcTenantClaim = "";
867
871
  if (requestedAuthMode === "basic") {
868
872
  const usersPath = process.env.OMCP_USERS_FILE;
869
873
  if (!usersPath) {
@@ -894,8 +898,18 @@ async function main() {
894
898
  }
895
899
  }
896
900
  else if (requestedAuthMode === "oidc") {
901
+ // SSO/OIDC is an entitled control. The OSS surface — anonymous, basic
902
+ // (local users), and API-key auth — stays free and unchanged; only
903
+ // delegating identity to an external IdP requires the `sso` entitlement.
904
+ // Fail-closed when actively requested without it (respects the same
905
+ // OMCP_AUTH_ALLOW_FALLBACK escape hatch as every other auth misconfig);
906
+ // a deployment that never sets OMCP_AUTH=oidc is never affected.
897
907
  const r = resolveOidcConfig(process.env);
898
- if (r.error || !r.config) {
908
+ if (!(await featureEntitled("sso"))) {
909
+ authMisconfig("OMCP_AUTH=oidc (SSO) requires an entitlement (sso feature). " +
910
+ "Use OMCP_AUTH=basic or api-key for the open-source single-sign-on-free setup");
911
+ }
912
+ else if (r.error || !r.config) {
899
913
  authMisconfig(r.error ?? "OIDC misconfigured");
900
914
  }
901
915
  else {
@@ -910,12 +924,35 @@ async function main() {
910
924
  sessionCfg = { secret };
911
925
  authMode = "oidc";
912
926
  oidcRuntime = buildOidcRuntime(r.config);
927
+ oidcTenantClaim = r.config.tenantClaim ?? "";
913
928
  console.log(`[auth] OIDC mode active — issuer=${r.config.issuer} clientId=${r.config.clientId} rolesClaim=${r.config.rolesClaim} mappedRoles=${Object.keys(r.config.roleMap).length}`);
914
929
  }
915
930
  }
916
931
  else if (requestedAuthMode !== "anonymous") {
917
932
  authMisconfig(`unknown OMCP_AUTH=${requestedAuthMode}`);
918
933
  }
934
+ // Multi-tenancy is an entitled control. The gateway is ALWAYS tenant-scoped,
935
+ // but every principal lands in DEFAULT_TENANT unless the operator actively
936
+ // maps identities to NON-default tenants — so the single-tenant default (the
937
+ // OSS path: anonymous, basic, api-key, or OIDC without a tenant claim) is
938
+ // free and bit-for-bit unchanged. It becomes "actively multi-tenant" only
939
+ // when an OIDC tenant claim is configured, or OMCP_KEY_TENANTS maps a
940
+ // credential to a non-default tenant. That configuration requires the
941
+ // `tenancy` entitlement; without it we fail closed and refuse to start,
942
+ // rather than silently collapsing isolated tenants into one (which could
943
+ // merge data across tenant boundaries). Single-tenant deployments never hit
944
+ // this branch.
945
+ const tenancyConfigured = isMultiTenantConfigured(oidcTenantClaim, process.env.OMCP_KEY_TENANTS);
946
+ if (tenancyConfigured && !(await featureEntitled("tenancy"))) {
947
+ const which = oidcTenantClaim
948
+ ? `an OIDC tenant claim (${oidcTenantClaim})`
949
+ : "OMCP_KEY_TENANTS with non-default tenants";
950
+ console.error(`[tenancy] ${which} configures multi-tenant isolation, which requires an ` +
951
+ "entitlement (tenancy feature) — refusing to start (fail-closed). " +
952
+ "For the open-source single-tenant setup, omit OMCP_OIDC_TENANT_CLAIM and " +
953
+ "keep OMCP_KEY_TENANTS at the default tenant.");
954
+ process.exit(1);
955
+ }
919
956
  // Session revocation blocklist (Q17). Only meaningful when sessions
920
957
  // exist (basic / oidc); anonymous mode leaves it undefined so the
921
958
  // middleware check is a pure no-op. OMCP_AUTH_REVOCATION_FILE persists
@@ -1216,6 +1253,50 @@ async function main() {
1216
1253
  // tool_pre_invoke / tool_post_invoke chains; resource and prompt
1217
1254
  // hooks plug into their respective seams as they ship.
1218
1255
  const hookRegistry = new HookRegistry();
1256
+ // Inspect (observe/learn/enforce). The recorder registers as a permissive
1257
+ // tool_post_invoke hook so it can never block or slow a tool call. Mode
1258
+ // defaults to "observe" (record-only, zero decision); OMCP_INSPECT can set
1259
+ // off/dryrun/enforce at boot, and the API can switch it at runtime. Profile
1260
+ // evaluation (dry-run/enforce) is wired in a later phase.
1261
+ const inspectStore = new InspectStore({ file: process.env.OMCP_INSPECT_FILE?.trim() || undefined });
1262
+ // Inspect ENFORCE (active blocking) is an entitled control; observe/dry-run
1263
+ // are free (OSS). Resolve the entitlement once at boot.
1264
+ const inspectEnforceAllowed = await inspectEnforceEntitled();
1265
+ // SCIM provisioning is likewise an entitled control. OFF by default (no
1266
+ // OMCP_SCIM_TOKEN) so the OSS surface is unchanged; resolve the entitlement
1267
+ // once here so both /api/info and the route-mount block agree.
1268
+ const scimConfigured = !!process.env.OMCP_SCIM_TOKEN?.trim();
1269
+ const scimEntitled = scimConfigured && (await featureEntitled("scim"));
1270
+ // One flat map of every entitled feature → bool, surfaced on /api/info so
1271
+ // the UI can render a consistent lock optic. Resolved once at boot (the
1272
+ // entitlement token is read at startup and never changes at runtime).
1273
+ const entitlements = await entitledFeatures();
1274
+ let inspectBootMode = bootMode(process.env.OMCP_INSPECT);
1275
+ if (inspectBootMode === "enforce" && !inspectEnforceAllowed) {
1276
+ console.warn("[inspect] OMCP_INSPECT=enforce requires an entitlement (inspect-enforce); running in dry-run.");
1277
+ inspectBootMode = "dryrun";
1278
+ }
1279
+ const inspectMode = new ModeController(inspectBootMode);
1280
+ // Behavior profile (the learned ruleset). Accepted rules drive the
1281
+ // evaluator: in dry-run the recorder records a `would-block` deviation for
1282
+ // calls outside the profile (never blocks — enforce blocking is a later
1283
+ // phase). Persists to OMCP_INSPECT_PROFILE_FILE when set.
1284
+ const inspectProfile = new ProfileStore({ file: process.env.OMCP_INSPECT_PROFILE_FILE?.trim() || undefined });
1285
+ hookRegistry.register(createInspectRecorder(inspectStore, inspectMode, {
1286
+ onEvent: (e) => recordInspectEvent(e.tool, e.outcome, e.decision),
1287
+ evaluator: inspectProfile,
1288
+ }));
1289
+ // Enforcer: pre-invoke gate that blocks (and records) calls outside the
1290
+ // accepted profile — only when mode is `enforce`. Pass-through otherwise.
1291
+ hookRegistry.register(createInspectEnforcer(inspectStore, inspectMode, inspectProfile, {
1292
+ onEvent: (e) => recordInspectEvent(e.tool, e.outcome, e.decision),
1293
+ // Belt-and-suspenders: never block without the enforce entitlement, even
1294
+ // if the mode were somehow set to enforce.
1295
+ enforceAllowed: () => inspectEnforceAllowed,
1296
+ }));
1297
+ if (inspectMode.get() !== "observe") {
1298
+ console.log(`[inspect] mode=${inspectMode.get()} (store ${inspectStore.persisted ? "persisted" : "in-memory"})`);
1299
+ }
1219
1300
  // Phase F15: anomaly-history sink — opt-in via
1220
1301
  // OMCP_ANOMALY_HISTORY_REMOTE_WRITE. When configured, anomaly
1221
1302
  // scores written via anomalyHistory.record() flush to the
@@ -1560,7 +1641,16 @@ async function main() {
1560
1641
  .getAll()
1561
1642
  .filter((c) => typeof c.queryTraces === "function").length,
1562
1643
  pluginsVerified: !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true"),
1563
- scimEnabled: !!process.env.OMCP_SCIM_TOKEN,
1644
+ scimEnabled: scimEntitled,
1645
+ scimConfigured,
1646
+ // Active multi-tenancy (non-default tenants configured). When true the
1647
+ // server is running, so the `tenancy` entitlement is necessarily
1648
+ // present — an unentitled multi-tenant config fails closed at boot.
1649
+ multiTenant: tenancyConfigured,
1650
+ // Per-feature entitlement map ({ "access-control": bool, audit, sso,
1651
+ // scim, tenancy, "inspect-enforce" }) so the UI shows a lock badge on
1652
+ // every entitled feature. All false on the OSS default (no token).
1653
+ entitlements,
1564
1654
  federationUpstreams: (process.env.OMCP_FEDERATION_UPSTREAMS ?? "")
1565
1655
  .split(",").map((s) => s.trim()).filter(Boolean).length,
1566
1656
  },
@@ -1999,6 +2089,184 @@ async function main() {
1999
2089
  scopedTo: tenantFilter || (isAdmin ? null : callerTenant),
2000
2090
  });
2001
2091
  });
2092
+ // --- /api/inspect — observe/learn/enforce surface (Inspect feature) ---
2093
+ // Reads need inspection:read; the mode switch needs inspection:write and is
2094
+ // audited. Non-admin callers are scoped to their own tenant's observations;
2095
+ // a cross-tenant admin sees all (or ?tenant=acme to filter).
2096
+ const inspectScope = (req) => {
2097
+ const sess = req.session;
2098
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2099
+ return isAdmin ? (qstr(req.query.tenant) || null) : (sess?.tenant || "default");
2100
+ };
2101
+ app.get("/api/inspect/mode", need("inspection", "read"), (_req, res) => {
2102
+ res.json({
2103
+ mode: inspectMode.get(),
2104
+ recording: inspectMode.recording,
2105
+ evaluating: inspectMode.evaluating,
2106
+ blocking: inspectMode.blocking,
2107
+ size: inspectStore.size,
2108
+ persisted: inspectStore.persisted,
2109
+ // observe/dry-run are free; enforce (active blocking) is an entitled
2110
+ // control. The UI uses this to lock the Enforce option when unlicensed.
2111
+ enforceEntitled: inspectEnforceAllowed,
2112
+ });
2113
+ });
2114
+ app.put("/api/inspect/mode", need("inspection", "write"), audit("inspection", "write"), (req, res) => {
2115
+ const body = req.body;
2116
+ // Enforce (active blocking) requires the inspect-enforce entitlement;
2117
+ // observe/dry-run are always available. Refuse the switch otherwise.
2118
+ if (typeof body?.mode === "string" && body.mode.trim().toLowerCase() === "enforce" && !inspectEnforceAllowed) {
2119
+ res.status(403).json({
2120
+ error: "Enforce mode requires an entitlement (inspect-enforce). Observe and dry-run are available without a license.",
2121
+ code: "OMCP_ENTITLEMENT_REQUIRED",
2122
+ });
2123
+ return;
2124
+ }
2125
+ try {
2126
+ const m = inspectMode.set(body?.mode);
2127
+ res.json({ mode: m });
2128
+ }
2129
+ catch (e) {
2130
+ res.status(400).json({ error: e instanceof Error ? e.message : "invalid mode" });
2131
+ }
2132
+ });
2133
+ app.get("/api/inspect/events", need("inspection", "read"), (req, res) => {
2134
+ const tenant = inspectScope(req);
2135
+ let events = inspectStore.list({
2136
+ from: qstr(req.query.from),
2137
+ to: qstr(req.query.to),
2138
+ principal: qstr(req.query.principal),
2139
+ tool: qstr(req.query.tool),
2140
+ outcome: qstr(req.query.outcome),
2141
+ decision: qstr(req.query.decision),
2142
+ limit: qstr(req.query.limit) ? parseInt(qstr(req.query.limit), 10) : undefined,
2143
+ });
2144
+ if (tenant)
2145
+ events = events.filter((e) => e.tenant === tenant);
2146
+ // Backend drill-down (G1): narrow to one backend (the most-specific
2147
+ // resource dim — service|source|namespace, matching the flow graph's
2148
+ // backend node). Applied client-of-store side so the ring filters stay
2149
+ // generic.
2150
+ const backend = qstr(req.query.backend);
2151
+ if (backend) {
2152
+ events = events.filter((e) => (e.service || e.source || e.namespace || "(unrouted)") === backend);
2153
+ }
2154
+ res.json({ events, mode: inspectMode.get(), persisted: inspectStore.persisted, scopedTo: tenant });
2155
+ });
2156
+ app.get("/api/inspect/flows", need("inspection", "read"), (req, res) => {
2157
+ const tenant = inspectScope(req);
2158
+ const windowSecs = durationToSeconds(qstr(req.query.window) || "24h") ?? 86400;
2159
+ const sinceMs = Date.now() - windowSecs * 1000;
2160
+ let obs = inspectStore.since(sinceMs);
2161
+ if (tenant)
2162
+ obs = obs.filter((e) => e.tenant === tenant);
2163
+ const graph = buildFlowGraph(obs, { sinceMs });
2164
+ res.json({ ...graph, mode: inspectMode.get(), scopedTo: tenant });
2165
+ });
2166
+ app.get("/api/inspect/profile", need("inspection", "read"), (_req, res) => {
2167
+ const rules = inspectProfile.list();
2168
+ res.json({
2169
+ rules,
2170
+ counts: {
2171
+ total: rules.length,
2172
+ suggested: rules.filter((r) => r.status === "suggested").length,
2173
+ accepted: rules.filter((r) => r.status === "accepted").length,
2174
+ rejected: rules.filter((r) => r.status === "rejected").length,
2175
+ },
2176
+ persisted: inspectProfile.persisted,
2177
+ });
2178
+ });
2179
+ // Learn: derive suggested rules from the observed window. Mutating (writes
2180
+ // the suggested set) → inspection:write + audited.
2181
+ app.post("/api/inspect/profile/derive", need("inspection", "write"), audit("inspection", "write"), (req, res) => {
2182
+ const windowSecs = durationToSeconds(qstr(req.query.window) || "24h") ?? 86400;
2183
+ const obs = inspectStore.since(Date.now() - windowSecs * 1000);
2184
+ const rules = inspectProfile.derive(obs);
2185
+ res.json({ rules, learnedFrom: obs.length, suggested: inspectProfile.suggested().length });
2186
+ });
2187
+ // Accept / reject / reset a rule, or edit its constraints.
2188
+ app.patch("/api/inspect/profile/rules/:id", need("inspection", "write"), audit("inspection", "write"), (req, res) => {
2189
+ const id = String(req.params.id);
2190
+ const body = (req.body || {});
2191
+ if (body.status != null) {
2192
+ const status = String(body.status);
2193
+ if (!["suggested", "accepted", "rejected"].includes(status)) {
2194
+ res.status(400).json({ error: "status must be suggested|accepted|rejected" });
2195
+ return;
2196
+ }
2197
+ const r = inspectProfile.setStatus(id, status);
2198
+ if (!r) {
2199
+ res.status(404).json({ error: "rule not found" });
2200
+ return;
2201
+ }
2202
+ res.json({ rule: r });
2203
+ return;
2204
+ }
2205
+ if (body.constraints != null || body.subject != null) {
2206
+ const r = inspectProfile.update(id, {
2207
+ constraints: body.constraints,
2208
+ subject: typeof body.subject === "string" ? body.subject : undefined,
2209
+ });
2210
+ if (!r) {
2211
+ res.status(404).json({ error: "rule not found" });
2212
+ return;
2213
+ }
2214
+ res.json({ rule: r });
2215
+ return;
2216
+ }
2217
+ res.status(400).json({ error: "provide status or constraints/subject" });
2218
+ });
2219
+ app.delete("/api/inspect/profile/rules/:id", need("inspection", "write"), audit("inspection", "write"), (req, res) => {
2220
+ const ok = inspectProfile.remove(String(req.params.id));
2221
+ if (!ok) {
2222
+ res.status(404).json({ error: "rule not found" });
2223
+ return;
2224
+ }
2225
+ res.json({ ok: true });
2226
+ });
2227
+ // Deviation → rule (one click): absorb exactly this observed call shape into
2228
+ // the accepted profile (widen the matching rule, or create a tight one).
2229
+ app.post("/api/inspect/profile/from-deviation", need("inspection", "write"), audit("inspection", "write"), (req, res) => {
2230
+ const b = (req.body || {});
2231
+ const principal = typeof b.principal === "string" ? b.principal : "";
2232
+ const tool = typeof b.tool === "string" ? b.tool : "";
2233
+ if (!principal || !tool) {
2234
+ res.status(400).json({ error: "principal and tool are required" });
2235
+ return;
2236
+ }
2237
+ const str = (v) => (typeof v === "string" && v ? v : undefined);
2238
+ const argShape = {};
2239
+ if (b.argShape && typeof b.argShape === "object") {
2240
+ // The arg-shape KEY is remote input. Accept only a conservative
2241
+ // identifier charset (arg names are simple tokens) and never the
2242
+ // prototype-polluting names — barrier for js/remote-property-injection
2243
+ // and prototype pollution.
2244
+ const SAFE_ARG_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
2245
+ for (const [k, v] of Object.entries(b.argShape)) {
2246
+ if (!SAFE_ARG_KEY.test(k) || k === "__proto__" || k === "constructor" || k === "prototype")
2247
+ continue;
2248
+ if (typeof v === "string")
2249
+ argShape[k] = v;
2250
+ }
2251
+ }
2252
+ const rule = inspectProfile.absorb({
2253
+ principal, tool,
2254
+ source: str(b.source), service: str(b.service), namespace: str(b.namespace),
2255
+ argShape,
2256
+ });
2257
+ res.json({ rule });
2258
+ });
2259
+ // Deviations: calls in the window that fell outside the accepted profile
2260
+ // (decision != allow). In dry-run these are would-block; in enforce, blocked.
2261
+ app.get("/api/inspect/deviations", need("inspection", "read"), (req, res) => {
2262
+ const tenant = inspectScope(req);
2263
+ const windowSecs = durationToSeconds(qstr(req.query.window) || "24h") ?? 86400;
2264
+ let evs = inspectStore.since(Date.now() - windowSecs * 1000).filter((e) => e.decision !== "allow");
2265
+ if (tenant)
2266
+ evs = evs.filter((e) => e.tenant === tenant);
2267
+ evs.reverse(); // newest first
2268
+ res.json({ deviations: evs, total: evs.length, mode: inspectMode.get(), scopedTo: tenant });
2269
+ });
2002
2270
  // --- /api/audit/dlq — webhook-sink dead-letter queue surface (P9) ---
2003
2271
  // When the audit webhook is configured AND the receiver exhausted
2004
2272
  // its retry budget, entries land in the DLQ file. This endpoint
@@ -2283,7 +2551,18 @@ async function main() {
2283
2551
  // multi-replica deployments stay coherent (Q6); the redis client is
2284
2552
  // built from OMCP_SCIM_REDIS_URL here, mirroring the session store.
2285
2553
  const scimToken = process.env.OMCP_SCIM_TOKEN?.trim();
2286
- if (scimToken) {
2554
+ // SCIM provisioning is an entitled control (entitlement resolved at boot as
2555
+ // `scimEntitled`). OFF by default (no OMCP_SCIM_TOKEN) → OSS surface
2556
+ // unchanged. Configured without the `scim` entitlement → fail closed: the
2557
+ // /scim/v2/* routes are NOT mounted and the dashboard store stays empty, so
2558
+ // no unentitled provisioning can happen — and the gateway keeps running (a
2559
+ // missing IdP integration must not take the whole server down).
2560
+ if (scimToken && !scimEntitled) {
2561
+ console.error("[scim] OMCP_SCIM_TOKEN is set but SCIM provisioning requires an entitlement " +
2562
+ "(scim feature) — refusing to mount /scim/v2/* (fail-closed). " +
2563
+ "Unset OMCP_SCIM_TOKEN to silence this, or provision an entitlement.");
2564
+ }
2565
+ if (scimToken && scimEntitled) {
2287
2566
  try {
2288
2567
  const scimBackend = (process.env.OMCP_SCIM_BACKEND?.trim() || "file");
2289
2568
  let scimRedis;
@@ -0,0 +1,19 @@
1
+ import type { HookRegistration } from "../sdk/hooks.js";
2
+ import type { InspectStore } from "./store.js";
3
+ import type { ModeController } from "./mode.js";
4
+ import { type ProfileEvaluator } from "./recorder.js";
5
+ export interface EnforcerOptions {
6
+ onEvent?: (e: {
7
+ tool: string;
8
+ outcome: "ok" | "error";
9
+ decision: "blocked";
10
+ }) => void;
11
+ /** Entitlement gate — when it returns false, the enforcer never blocks (enforce
12
+ * is an entitled control; observe/dry-run are free). Defaults to allowed. */
13
+ enforceAllowed?: () => boolean;
14
+ }
15
+ /**
16
+ * Build the enforce-mode pre-invoke hook. Blocks (and records) calls outside
17
+ * the accepted profile only when mode is `enforce`; pass-through otherwise.
18
+ */
19
+ export declare function createInspectEnforcer(store: InspectStore, mode: ModeController, evaluator: ProfileEvaluator, opts?: EnforcerOptions): HookRegistration;
@@ -0,0 +1,69 @@
1
+ // Inspect — the enforcer.
2
+ //
3
+ // A `tool_pre_invoke` hook that BLOCKS calls falling outside the accepted
4
+ // profile, but ONLY when the mode is `enforce`. In observe/dry-run it is a
5
+ // pass-through (dry-run's would-block recording happens in the post-invoke
6
+ // recorder). When it blocks, it records a `blocked` observation itself —
7
+ // because a pre-invoke denial short-circuits the dispatch, so the post-invoke
8
+ // recorder never runs for blocked calls (no double-recording).
9
+ //
10
+ // Fail-open: any internal error returns allow:true. An inspector bug must
11
+ // never become a denial-of-service for the agent's tools.
12
+ import { redactValue } from "../policy/redact.js";
13
+ import { deriveSignature } from "./signature.js";
14
+ import { authKind } from "./recorder.js";
15
+ /**
16
+ * Build the enforce-mode pre-invoke hook. Blocks (and records) calls outside
17
+ * the accepted profile only when mode is `enforce`; pass-through otherwise.
18
+ */
19
+ export function createInspectEnforcer(store, mode, evaluator, opts = {}) {
20
+ const handler = (ctx, payload) => {
21
+ try {
22
+ if (!mode.blocking)
23
+ return { allow: true };
24
+ // Enforce blocking is an entitled control; without it, pass through.
25
+ if (opts.enforceAllowed && !opts.enforceAllowed())
26
+ return { allow: true };
27
+ const red = redactValue(payload.args);
28
+ const sig = deriveSignature(ctx.target, red.value);
29
+ const ev = evaluator.evaluate({
30
+ principal: ctx.principal, tool: ctx.target,
31
+ source: sig.source, service: sig.service, namespace: sig.namespace,
32
+ argShape: sig.argShape,
33
+ });
34
+ if (ev.verdict === "deviation") {
35
+ store.record({
36
+ principal: ctx.principal,
37
+ auth: authKind(ctx.principal),
38
+ tenant: ctx.tenant,
39
+ tool: ctx.target,
40
+ source: sig.source,
41
+ service: sig.service,
42
+ namespace: sig.namespace,
43
+ argShape: sig.argShape,
44
+ outcome: "error",
45
+ decision: "blocked",
46
+ deviation: ev.kind,
47
+ redactions: red.totalMatches,
48
+ });
49
+ opts.onEvent?.({ tool: ctx.target, outcome: "error", decision: "blocked" });
50
+ return {
51
+ allow: false,
52
+ reason: `Blocked by the inspection profile (${ev.kind ?? "deviation"}). This call falls outside the accepted baseline for ${ctx.principal}; review it under Inspect → Deviations.`,
53
+ };
54
+ }
55
+ }
56
+ catch {
57
+ // Fail open — an inspector error must never block a tool call.
58
+ return { allow: true };
59
+ }
60
+ return { allow: true };
61
+ };
62
+ return {
63
+ pluginName: "inspect-enforcer",
64
+ kind: "tool_pre_invoke",
65
+ priority: 5,
66
+ mode: "permissive",
67
+ handler,
68
+ };
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createInspectEnforcer } from "./enforcer.js";
4
+ import { InspectStore } from "./store.js";
5
+ import { ModeController } from "./mode.js";
6
+ const ctx = (over = {}) => ({
7
+ principal: "key:bot", tenant: "default", kind: "tool_pre_invoke", target: "query_logs", ...over,
8
+ });
9
+ const allowEval = { evaluate: () => ({ verdict: "allow" }) };
10
+ const denyEval = { evaluate: () => ({ verdict: "deviation", kind: "new-resource" }) };
11
+ describe("createInspectEnforcer", () => {
12
+ it("registers as a permissive tool_pre_invoke hook", () => {
13
+ const reg = createInspectEnforcer(new InspectStore(), new ModeController("enforce"), allowEval);
14
+ assert.equal(reg.kind, "tool_pre_invoke");
15
+ assert.equal(reg.mode, "permissive");
16
+ assert.equal(reg.pluginName, "inspect-enforcer");
17
+ });
18
+ it("enforce: BLOCKS a deviation and records a blocked observation", async () => {
19
+ const store = new InspectStore();
20
+ const reg = createInspectEnforcer(store, new ModeController("enforce"), denyEval);
21
+ const r = await reg.handler(ctx({ target: "query_logs" }), { args: { service: "novel" } });
22
+ assert.equal(r.allow, false);
23
+ assert.match(r.reason, /Blocked by the inspection profile/);
24
+ assert.match(r.reason, /new-resource/);
25
+ const o = store.all()[0];
26
+ assert.equal(o.decision, "blocked");
27
+ assert.equal(o.deviation, "new-resource");
28
+ assert.equal(o.tool, "query_logs");
29
+ });
30
+ it("enforce: ALLOWS an in-profile call and records nothing (post-invoke recorder will)", async () => {
31
+ const store = new InspectStore();
32
+ const reg = createInspectEnforcer(store, new ModeController("enforce"), allowEval);
33
+ const r = await reg.handler(ctx(), { args: {} });
34
+ assert.deepEqual(r, { allow: true });
35
+ assert.equal(store.size, 0);
36
+ });
37
+ it("observe + dry-run never block (pass-through, no eval)", async () => {
38
+ for (const mode of ["off", "observe", "dryrun"]) {
39
+ let consulted = false;
40
+ const evaluator = { evaluate: () => { consulted = true; return { verdict: "deviation", kind: "new-tool" }; } };
41
+ const store = new InspectStore();
42
+ const reg = createInspectEnforcer(store, new ModeController(mode), evaluator);
43
+ const r = await reg.handler(ctx(), { args: {} });
44
+ assert.deepEqual(r, { allow: true }, `mode=${mode} must pass through`);
45
+ assert.equal(consulted, false, `mode=${mode} must not evaluate`);
46
+ assert.equal(store.size, 0);
47
+ }
48
+ });
49
+ it("never blocks when the enforce entitlement is absent (enforceAllowed=false)", async () => {
50
+ const store = new InspectStore();
51
+ const reg = createInspectEnforcer(store, new ModeController("enforce"), denyEval, { enforceAllowed: () => false });
52
+ const r = await reg.handler(ctx(), { args: { service: "novel" } });
53
+ assert.deepEqual(r, { allow: true }, "unlicensed enforce must not block");
54
+ assert.equal(store.size, 0, "nothing recorded as blocked when unlicensed");
55
+ });
56
+ it("blocks when the enforce entitlement is present (enforceAllowed=true)", async () => {
57
+ const reg = createInspectEnforcer(new InspectStore(), new ModeController("enforce"), denyEval, { enforceAllowed: () => true });
58
+ const r = await reg.handler(ctx(), { args: {} });
59
+ assert.equal(r.allow, false);
60
+ });
61
+ it("fails OPEN — an evaluator that throws never blocks the call", async () => {
62
+ const store = new InspectStore();
63
+ const boom = { evaluate: () => { throw new Error("inspector bug"); } };
64
+ const reg = createInspectEnforcer(store, new ModeController("enforce"), boom);
65
+ const r = await reg.handler(ctx(), { args: {} });
66
+ assert.deepEqual(r, { allow: true });
67
+ });
68
+ it("fires the onEvent metrics seam on a block", async () => {
69
+ const seen = [];
70
+ const reg = createInspectEnforcer(new InspectStore(), new ModeController("enforce"), denyEval, {
71
+ onEvent: (e) => seen.push(e),
72
+ });
73
+ await reg.handler(ctx({ target: "enrich_ips" }), { args: {} });
74
+ assert.deepEqual(seen, [{ tool: "enrich_ips", outcome: "error", decision: "blocked" }]);
75
+ });
76
+ });
@@ -0,0 +1,33 @@
1
+ import type { Observation } from "./store.js";
2
+ export type FlowNodeKind = "identity" | "tool" | "backend";
3
+ export interface FlowNode {
4
+ id: string;
5
+ kind: FlowNodeKind;
6
+ label: string;
7
+ calls: number;
8
+ errors: number;
9
+ deviations: number;
10
+ }
11
+ export interface FlowEdge {
12
+ from: string;
13
+ to: string;
14
+ count: number;
15
+ allow: number;
16
+ deviation: number;
17
+ denied: number;
18
+ }
19
+ export interface FlowGraph {
20
+ nodes: FlowNode[];
21
+ edges: FlowEdge[];
22
+ total: number;
23
+ windowMs: number | null;
24
+ generatedFrom: number;
25
+ }
26
+ /** The backend a call targeted: the most specific resource dimension. */
27
+ export declare function backendOf(o: Observation): string;
28
+ export interface BuildFlowOptions {
29
+ /** Only include observations at or after this epoch-ms instant. */
30
+ sinceMs?: number;
31
+ }
32
+ /** Build the flow graph from a list of observations. */
33
+ export declare function buildFlowGraph(observations: Observation[], opts?: BuildFlowOptions): FlowGraph;
Binary file
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildFlowGraph, backendOf } from "./graph.js";
4
+ let seq = 0;
5
+ function obs(over = {}) {
6
+ return {
7
+ ts: new Date(1_700_000_000_000 + seq * 1000).toISOString(),
8
+ seq: ++seq,
9
+ principal: "key:bot",
10
+ auth: "apikey",
11
+ tenant: "default",
12
+ tool: "query_logs",
13
+ argShape: {},
14
+ outcome: "ok",
15
+ decision: "allow",
16
+ redactions: 0,
17
+ ...over,
18
+ };
19
+ }
20
+ describe("backendOf", () => {
21
+ it("prefers service > source > namespace > (unrouted)", () => {
22
+ assert.equal(backendOf(obs({ service: "pay", source: "p", namespace: "n" })), "pay");
23
+ assert.equal(backendOf(obs({ source: "p", namespace: "n" })), "p");
24
+ assert.equal(backendOf(obs({ namespace: "n" })), "n");
25
+ assert.equal(backendOf(obs({})), "(unrouted)");
26
+ });
27
+ });
28
+ describe("buildFlowGraph", () => {
29
+ it("builds identity→tool→backend nodes and edges", () => {
30
+ const g = buildFlowGraph([
31
+ obs({ principal: "alice", tool: "query_logs", service: "pay" }),
32
+ obs({ principal: "alice", tool: "query_logs", service: "pay" }),
33
+ obs({ principal: "bob", tool: "query_metrics", source: "prom" }),
34
+ ]);
35
+ assert.equal(g.total, 3);
36
+ const kinds = g.nodes.reduce((m, n) => ((m[n.kind] = (m[n.kind] || 0) + 1), m), {});
37
+ assert.equal(kinds.identity, 2);
38
+ assert.equal(kinds.tool, 2);
39
+ assert.equal(kinds.backend, 2);
40
+ // alice→query_logs edge has count 2
41
+ const e = g.edges.find((x) => x.from === "identity:alice" && x.to === "tool:query_logs");
42
+ assert.equal(e?.count, 2);
43
+ assert.equal(e?.allow, 2);
44
+ });
45
+ it("breaks edges down by decision and counts node errors/deviations", () => {
46
+ const g = buildFlowGraph([
47
+ obs({ principal: "a", tool: "t", service: "s", decision: "allow", outcome: "ok" }),
48
+ obs({ principal: "a", tool: "t", service: "s", decision: "would-block", outcome: "ok" }),
49
+ obs({ principal: "a", tool: "t", service: "s", decision: "blocked", outcome: "error" }),
50
+ ]);
51
+ const e = g.edges.find((x) => x.from === "tool:t" && x.to === "backend:s");
52
+ assert.equal(e.allow, 1);
53
+ assert.equal(e.deviation, 1);
54
+ assert.equal(e.denied, 1);
55
+ const toolNode = g.nodes.find((n) => n.id === "tool:t");
56
+ assert.equal(toolNode.calls, 3);
57
+ assert.equal(toolNode.errors, 1);
58
+ assert.equal(toolNode.deviations, 2); // would-block + blocked
59
+ });
60
+ it("honours the sinceMs window filter", () => {
61
+ const old = obs({ ts: new Date(1_000_000_000_000).toISOString(), tool: "old" });
62
+ const fresh = obs({ ts: new Date(1_700_000_500_000).toISOString(), tool: "fresh" });
63
+ const g = buildFlowGraph([old, fresh], { sinceMs: 1_700_000_000_000 });
64
+ assert.equal(g.total, 1);
65
+ assert.ok(g.nodes.some((n) => n.id === "tool:fresh"));
66
+ assert.ok(!g.nodes.some((n) => n.id === "tool:old"));
67
+ });
68
+ it("empty input yields an empty graph", () => {
69
+ const g = buildFlowGraph([]);
70
+ assert.deepEqual(g.nodes, []);
71
+ assert.deepEqual(g.edges, []);
72
+ assert.equal(g.total, 0);
73
+ });
74
+ });
@@ -0,0 +1,8 @@
1
+ export * from "./signature.js";
2
+ export * from "./store.js";
3
+ export * from "./mode.js";
4
+ export * from "./graph.js";
5
+ export * from "./recorder.js";
6
+ export * from "./enforcer.js";
7
+ export * from "./profile.js";
8
+ export * from "./profile-store.js";