@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.
- 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/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 +285 -6
- 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/tenancy/context.d.ts +7 -0
- package/dist/tenancy/context.js +15 -0
- package/dist/tenancy/context.test.js +18 -1
- package/dist/ui/index.html +742 -0
- 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 (
|
|
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:
|
|
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
|
-
|
|
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
|
+
});
|