@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.
- package/dist/auth/policy/loader.js +1 -1
- package/dist/auth/rbac.d.ts +1 -1
- package/dist/auth/rbac.js +3 -1
- package/dist/auth/rbac.test.js +5 -3
- package/dist/conformance/inspect-e2e.test.d.ts +1 -0
- package/dist/conformance/inspect-e2e.test.js +104 -0
- package/dist/connectors/loader.js +30 -0
- package/dist/connectors/loader.test.js +11 -0
- package/dist/enrich/rdap.d.ts +40 -0
- package/dist/enrich/rdap.js +122 -0
- package/dist/enrich/rdap.test.d.ts +1 -0
- package/dist/enrich/rdap.test.js +78 -0
- package/dist/enterprise-gate.d.ts +28 -0
- package/dist/enterprise-gate.js +51 -0
- package/dist/enterprise-gate.test.js +21 -1
- package/dist/index.js +296 -8
- package/dist/inspect/enforcer.d.ts +19 -0
- package/dist/inspect/enforcer.js +69 -0
- package/dist/inspect/enforcer.test.d.ts +1 -0
- package/dist/inspect/enforcer.test.js +76 -0
- package/dist/inspect/graph.d.ts +33 -0
- package/dist/inspect/graph.js +0 -0
- package/dist/inspect/graph.test.d.ts +1 -0
- package/dist/inspect/graph.test.js +74 -0
- package/dist/inspect/index.d.ts +8 -0
- package/dist/inspect/index.js +13 -0
- package/dist/inspect/mode.d.ts +20 -0
- package/dist/inspect/mode.js +57 -0
- package/dist/inspect/mode.test.d.ts +1 -0
- package/dist/inspect/mode.test.js +53 -0
- package/dist/inspect/profile-store.d.ts +42 -0
- package/dist/inspect/profile-store.js +139 -0
- package/dist/inspect/profile-store.test.d.ts +1 -0
- package/dist/inspect/profile-store.test.js +82 -0
- package/dist/inspect/profile.d.ts +51 -0
- package/dist/inspect/profile.js +111 -0
- package/dist/inspect/profile.test.d.ts +1 -0
- package/dist/inspect/profile.test.js +96 -0
- package/dist/inspect/recorder.d.ts +42 -0
- package/dist/inspect/recorder.js +72 -0
- package/dist/inspect/recorder.test.d.ts +1 -0
- package/dist/inspect/recorder.test.js +112 -0
- package/dist/inspect/signature.d.ts +32 -0
- package/dist/inspect/signature.js +200 -0
- package/dist/inspect/signature.test.d.ts +1 -0
- package/dist/inspect/signature.test.js +136 -0
- package/dist/inspect/store.d.ts +62 -0
- package/dist/inspect/store.js +76 -0
- package/dist/inspect/store.test.d.ts +1 -0
- package/dist/inspect/store.test.js +78 -0
- package/dist/metrics/self.d.ts +3 -0
- package/dist/metrics/self.js +19 -0
- package/dist/net/egress-policy.js +1 -0
- package/dist/tenancy/context.d.ts +7 -0
- package/dist/tenancy/context.js +15 -0
- package/dist/tenancy/context.test.js +18 -1
- package/dist/tools/enrich-ips.d.ts +6 -2
- package/dist/tools/enrich-ips.js +32 -11
- package/dist/tools/enrich-ips.test.js +55 -11
- package/dist/ui/index.html +742 -0
- 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.
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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 {};
|