@thotischner/observability-mcp 3.6.1 → 3.8.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.
Files changed (61) 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/enrich/rdap.d.ts +40 -0
  10. package/dist/enrich/rdap.js +122 -0
  11. package/dist/enrich/rdap.test.d.ts +1 -0
  12. package/dist/enrich/rdap.test.js +78 -0
  13. package/dist/enterprise-gate.d.ts +28 -0
  14. package/dist/enterprise-gate.js +51 -0
  15. package/dist/enterprise-gate.test.js +21 -1
  16. package/dist/index.js +296 -8
  17. package/dist/inspect/enforcer.d.ts +19 -0
  18. package/dist/inspect/enforcer.js +69 -0
  19. package/dist/inspect/enforcer.test.d.ts +1 -0
  20. package/dist/inspect/enforcer.test.js +76 -0
  21. package/dist/inspect/graph.d.ts +33 -0
  22. package/dist/inspect/graph.js +0 -0
  23. package/dist/inspect/graph.test.d.ts +1 -0
  24. package/dist/inspect/graph.test.js +74 -0
  25. package/dist/inspect/index.d.ts +8 -0
  26. package/dist/inspect/index.js +13 -0
  27. package/dist/inspect/mode.d.ts +20 -0
  28. package/dist/inspect/mode.js +57 -0
  29. package/dist/inspect/mode.test.d.ts +1 -0
  30. package/dist/inspect/mode.test.js +53 -0
  31. package/dist/inspect/profile-store.d.ts +42 -0
  32. package/dist/inspect/profile-store.js +139 -0
  33. package/dist/inspect/profile-store.test.d.ts +1 -0
  34. package/dist/inspect/profile-store.test.js +82 -0
  35. package/dist/inspect/profile.d.ts +51 -0
  36. package/dist/inspect/profile.js +111 -0
  37. package/dist/inspect/profile.test.d.ts +1 -0
  38. package/dist/inspect/profile.test.js +96 -0
  39. package/dist/inspect/recorder.d.ts +42 -0
  40. package/dist/inspect/recorder.js +72 -0
  41. package/dist/inspect/recorder.test.d.ts +1 -0
  42. package/dist/inspect/recorder.test.js +112 -0
  43. package/dist/inspect/signature.d.ts +32 -0
  44. package/dist/inspect/signature.js +200 -0
  45. package/dist/inspect/signature.test.d.ts +1 -0
  46. package/dist/inspect/signature.test.js +136 -0
  47. package/dist/inspect/store.d.ts +62 -0
  48. package/dist/inspect/store.js +76 -0
  49. package/dist/inspect/store.test.d.ts +1 -0
  50. package/dist/inspect/store.test.js +78 -0
  51. package/dist/metrics/self.d.ts +3 -0
  52. package/dist/metrics/self.js +19 -0
  53. package/dist/net/egress-policy.js +1 -0
  54. package/dist/tenancy/context.d.ts +7 -0
  55. package/dist/tenancy/context.js +15 -0
  56. package/dist/tenancy/context.test.js +18 -1
  57. package/dist/tools/enrich-ips.d.ts +6 -2
  58. package/dist/tools/enrich-ips.js +32 -11
  59. package/dist/tools/enrich-ips.test.js +55 -11
  60. package/dist/ui/index.html +742 -0
  61. package/package.json +3 -2
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";
@@ -61,6 +62,7 @@ import { queryMetricsHandler } from "./tools/query-metrics.js";
61
62
  import { queryLogsHandler } from "./tools/query-logs.js";
62
63
  import { enrichIpsHandler } from "./tools/enrich-ips.js";
63
64
  import { IpEnrichmentDataset } from "./enrich/ip-dataset.js";
65
+ import { RdapResolver } from "./enrich/rdap.js";
64
66
  import { queryTracesHandler } from "./tools/query-traces.js";
65
67
  import { getAnomalyHistoryHandler } from "./tools/get-anomaly-history.js";
66
68
  import { generatePostmortemHandler } from "./tools/generate-postmortem.js";
@@ -299,6 +301,14 @@ async function main() {
299
301
  console.error(`[enrich] failed to load OMCP_IP_ENRICH_FILE (${ipEnrichFile}): ${err instanceof Error ? err.message : String(err)} — enrich_ips will report 'not configured'`);
300
302
  }
301
303
  }
304
+ // Optional ONLINE RDAP fallback (issue #477) — OFF by default to keep the
305
+ // air-gapped guarantee. Built only when OMCP_IP_ENRICH_RDAP is truthy; the
306
+ // offline CSV stays preferred and RDAP only fills gaps it didn't cover.
307
+ let ipRdap = null;
308
+ if (["on", "true", "1"].includes(String(process.env.OMCP_IP_ENRICH_RDAP ?? "").toLowerCase())) {
309
+ ipRdap = new RdapResolver({ baseUrl: process.env.OMCP_IP_ENRICH_RDAP_URL?.trim() || undefined });
310
+ console.log("[enrich] RDAP online fallback ENABLED (OMCP_IP_ENRICH_RDAP) — enrich_ips will query rdap.org for gaps the offline dataset doesn't cover");
311
+ }
302
312
  function redactToolText(result, opts = {}) {
303
313
  if (!REDACTION_ENABLED)
304
314
  return result;
@@ -783,7 +793,7 @@ async function main() {
783
793
  registerTool("enrich_ips", [
784
794
  "Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
785
795
  "When to use: answering 'where are these visitors from?' or 'which of these IPs are bots / datacenter / VPN exit nodes?' over access logs, without an out-of-band geo-API call per IP. Both IPv4 and IPv6 clients are resolved — don't pre-filter v6 out.",
786
- "Behavior: read-only. Looks each IP up in a LOCAL offline dataset the operator configured (OMCP_IP_ENRICH_FILE) there is no external network call, so it is safe in air-gapped deployments. Returns one row per input IP with found=true/false plus any known fields. If no dataset is configured it returns a clear notice explaining how to enable it.",
796
+ "Behavior: read-only. By default looks each IP up in a LOCAL offline dataset the operator configured (OMCP_IP_ENRICH_FILE) with NO external network call safe in air-gapped deployments. Optionally, if the operator enabled OMCP_IP_ENRICH_RDAP, IPs the dataset doesn't cover fall back to an online RDAP query (country/org only) and the result carries via:'rdap'; the offline dataset is always preferred. Returns one row per input IP with found=true/false plus any known fields. If neither is configured it returns a clear notice explaining how to enable them.",
787
797
  "Related: pull the IPs from `query_logs` (use `labels`/`aggregate` to find the IPs of interest first).",
788
798
  ].join(" "), {
789
799
  ips: z
@@ -791,7 +801,7 @@ async function main() {
791
801
  .describe("Required. IPv4 or IPv6 address strings to enrich (e.g. ['203.0.113.5','2001:db8::1']). Max 1000 per call; invalid entries are returned with found=false rather than failing the batch."),
792
802
  }, { title: "Enrich IPs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (args) => {
793
803
  await enforceEntitledAccess(ctx, { tool: "enrich_ips" });
794
- return withToolMetrics("enrich_ips", async () => enrichIpsHandler(ipEnrichment, args, ctx));
804
+ return withToolMetrics("enrich_ips", async () => enrichIpsHandler(ipEnrichment, args, ctx, ipRdap));
795
805
  });
796
806
  // Phase F10: federated tools — every upstream MCP server's tools
797
807
  // show up here under `<prefix>.<upstream-tool>`. The handler is a
@@ -855,6 +865,9 @@ async function main() {
855
865
  let usersStore = null;
856
866
  let secretEphemeral = false;
857
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 = "";
858
871
  if (requestedAuthMode === "basic") {
859
872
  const usersPath = process.env.OMCP_USERS_FILE;
860
873
  if (!usersPath) {
@@ -885,8 +898,18 @@ async function main() {
885
898
  }
886
899
  }
887
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.
888
907
  const r = resolveOidcConfig(process.env);
889
- 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) {
890
913
  authMisconfig(r.error ?? "OIDC misconfigured");
891
914
  }
892
915
  else {
@@ -901,12 +924,35 @@ async function main() {
901
924
  sessionCfg = { secret };
902
925
  authMode = "oidc";
903
926
  oidcRuntime = buildOidcRuntime(r.config);
927
+ oidcTenantClaim = r.config.tenantClaim ?? "";
904
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}`);
905
929
  }
906
930
  }
907
931
  else if (requestedAuthMode !== "anonymous") {
908
932
  authMisconfig(`unknown OMCP_AUTH=${requestedAuthMode}`);
909
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
+ }
910
956
  // Session revocation blocklist (Q17). Only meaningful when sessions
911
957
  // exist (basic / oidc); anonymous mode leaves it undefined so the
912
958
  // middleware check is a pure no-op. OMCP_AUTH_REVOCATION_FILE persists
@@ -1207,6 +1253,50 @@ async function main() {
1207
1253
  // tool_pre_invoke / tool_post_invoke chains; resource and prompt
1208
1254
  // hooks plug into their respective seams as they ship.
1209
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
+ }
1210
1300
  // Phase F15: anomaly-history sink — opt-in via
1211
1301
  // OMCP_ANOMALY_HISTORY_REMOTE_WRITE. When configured, anomaly
1212
1302
  // scores written via anomalyHistory.record() flush to the
@@ -1551,7 +1641,16 @@ async function main() {
1551
1641
  .getAll()
1552
1642
  .filter((c) => typeof c.queryTraces === "function").length,
1553
1643
  pluginsVerified: !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true"),
1554
- 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,
1555
1654
  federationUpstreams: (process.env.OMCP_FEDERATION_UPSTREAMS ?? "")
1556
1655
  .split(",").map((s) => s.trim()).filter(Boolean).length,
1557
1656
  },
@@ -1990,6 +2089,184 @@ async function main() {
1990
2089
  scopedTo: tenantFilter || (isAdmin ? null : callerTenant),
1991
2090
  });
1992
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
+ });
1993
2270
  // --- /api/audit/dlq — webhook-sink dead-letter queue surface (P9) ---
1994
2271
  // When the audit webhook is configured AND the receiver exhausted
1995
2272
  // its retry budget, entries land in the DLQ file. This endpoint
@@ -2274,7 +2551,18 @@ async function main() {
2274
2551
  // multi-replica deployments stay coherent (Q6); the redis client is
2275
2552
  // built from OMCP_SCIM_REDIS_URL here, mirroring the session store.
2276
2553
  const scimToken = process.env.OMCP_SCIM_TOKEN?.trim();
2277
- 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) {
2278
2566
  try {
2279
2567
  const scimBackend = (process.env.OMCP_SCIM_BACKEND?.trim() || "file");
2280
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 {};