@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { deriveSignature, countBucket, durationToSeconds, durationBucket, numBucket, queryFingerprint, } from "./signature.js";
|
|
4
|
+
describe("buckets", () => {
|
|
5
|
+
it("countBucket bands", () => {
|
|
6
|
+
assert.equal(countBucket(0), "1");
|
|
7
|
+
assert.equal(countBucket(1), "1");
|
|
8
|
+
assert.equal(countBucket(7), "2-10");
|
|
9
|
+
assert.equal(countBucket(50), "11-100");
|
|
10
|
+
assert.equal(countBucket(900), "101-1000");
|
|
11
|
+
assert.equal(countBucket(5000), ">1000");
|
|
12
|
+
});
|
|
13
|
+
it("durationToSeconds parses prom/loki durations", () => {
|
|
14
|
+
assert.equal(durationToSeconds("5m"), 300);
|
|
15
|
+
assert.equal(durationToSeconds("1h"), 3600);
|
|
16
|
+
assert.equal(durationToSeconds("1h30m"), 5400);
|
|
17
|
+
assert.equal(durationToSeconds("2d"), 172800);
|
|
18
|
+
assert.equal(durationToSeconds("250ms"), 0.25);
|
|
19
|
+
});
|
|
20
|
+
it("durationToSeconds rejects non-durations", () => {
|
|
21
|
+
assert.equal(durationToSeconds("not-a-duration"), null);
|
|
22
|
+
assert.equal(durationToSeconds("100"), null);
|
|
23
|
+
assert.equal(durationToSeconds(""), null);
|
|
24
|
+
});
|
|
25
|
+
it("durationBucket bands + 'other' for junk", () => {
|
|
26
|
+
assert.equal(durationBucket("30s"), "<=5m");
|
|
27
|
+
assert.equal(durationBucket("45m"), "<=1h");
|
|
28
|
+
assert.equal(durationBucket("12h"), "<=1d");
|
|
29
|
+
assert.equal(durationBucket("7d"), ">1d");
|
|
30
|
+
assert.equal(durationBucket("garbage"), "other");
|
|
31
|
+
});
|
|
32
|
+
it("numBucket bands", () => {
|
|
33
|
+
assert.equal(numBucket(5), "<=10");
|
|
34
|
+
assert.equal(numBucket(50), "<=100");
|
|
35
|
+
assert.equal(numBucket(500), "<=1000");
|
|
36
|
+
assert.equal(numBucket(9999), ">1000");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("queryFingerprint", () => {
|
|
40
|
+
it("blank → empty", () => {
|
|
41
|
+
assert.equal(queryFingerprint(""), "empty");
|
|
42
|
+
assert.equal(queryFingerprint(" "), "empty");
|
|
43
|
+
});
|
|
44
|
+
it("extracts func + metric + label from a PromQL rate query", () => {
|
|
45
|
+
const fp = queryFingerprint('rate(http_requests_total{job="api"}[5m])');
|
|
46
|
+
assert.match(fp, /f:rate/);
|
|
47
|
+
assert.match(fp, /m:http_requests_total/);
|
|
48
|
+
assert.match(fp, /l:job/);
|
|
49
|
+
// the range fragment "m" and the quoted value "api" are NOT metrics
|
|
50
|
+
assert.ok(!/[, ]m[, ]|m:.*\bm\b/.test(fp.replace("http_requests_total", "")));
|
|
51
|
+
assert.ok(!fp.includes("api"));
|
|
52
|
+
});
|
|
53
|
+
it("treats by()-grouping labels as labels, not metrics", () => {
|
|
54
|
+
const fp = queryFingerprint("sum by (instance) (up)");
|
|
55
|
+
assert.match(fp, /f:sum/);
|
|
56
|
+
assert.match(fp, /m:up/);
|
|
57
|
+
assert.match(fp, /l:instance/);
|
|
58
|
+
});
|
|
59
|
+
it("LogQL stream selector → label keys", () => {
|
|
60
|
+
const fp = queryFingerprint('{service_name="payment", level="error"} |= "boom"');
|
|
61
|
+
assert.match(fp, /l:level,service_name/);
|
|
62
|
+
assert.ok(!fp.includes("payment") && !fp.includes("boom"));
|
|
63
|
+
});
|
|
64
|
+
it("is deterministic + sorted + capped", () => {
|
|
65
|
+
const a = queryFingerprint('sum(rate(a_total[1m])) + avg(b_total)');
|
|
66
|
+
const b = queryFingerprint('avg(b_total) + sum(rate(a_total[1m]))');
|
|
67
|
+
assert.equal(a, b);
|
|
68
|
+
assert.match(a, /f:avg,rate,sum/);
|
|
69
|
+
assert.match(a, /m:a_total,b_total/);
|
|
70
|
+
});
|
|
71
|
+
it("is fast on a huge attacker-supplied query (no ReDoS)", () => {
|
|
72
|
+
const t0 = Date.now();
|
|
73
|
+
queryFingerprint("a".repeat(100000));
|
|
74
|
+
queryFingerprint("(".repeat(50000));
|
|
75
|
+
queryFingerprint("rate(".repeat(20000));
|
|
76
|
+
assert.ok(Date.now() - t0 < 100, "fingerprint stays linear/bounded on large input");
|
|
77
|
+
});
|
|
78
|
+
it("non-query free text with no structure → present", () => {
|
|
79
|
+
assert.equal(queryFingerprint("just some words"), "m:just,some,words"); // bare idents count as metrics
|
|
80
|
+
assert.equal(queryFingerprint("!!!"), "present");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("deriveSignature query fingerprinting", () => {
|
|
84
|
+
it("query/expr keys get a fingerprint, not 'present'", () => {
|
|
85
|
+
const sig = deriveSignature("query_metrics", { service: "pay", query: 'rate(http_requests_total[5m])' });
|
|
86
|
+
assert.match(sig.argShape.query, /m:http_requests_total/);
|
|
87
|
+
assert.match(sig.argShape.query, /f:rate/);
|
|
88
|
+
});
|
|
89
|
+
it("non-query free text still collapses to 'present'", () => {
|
|
90
|
+
const sig = deriveSignature("x", { note: "hello world" });
|
|
91
|
+
assert.equal(sig.argShape.note, "present");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("deriveSignature", () => {
|
|
95
|
+
it("lifts source/service/namespace as real resource dimensions", () => {
|
|
96
|
+
const sig = deriveSignature("query_logs", { source: "prom-eu", service: "payment", namespace: "omcp" });
|
|
97
|
+
assert.equal(sig.source, "prom-eu");
|
|
98
|
+
assert.equal(sig.service, "payment");
|
|
99
|
+
assert.equal(sig.namespace, "omcp");
|
|
100
|
+
});
|
|
101
|
+
it("buckets array length, never keeps elements", () => {
|
|
102
|
+
const sig = deriveSignature("enrich_ips", { ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"] });
|
|
103
|
+
assert.equal(sig.argShape.ips, "n=2-10");
|
|
104
|
+
assert.ok(!("source" in sig));
|
|
105
|
+
});
|
|
106
|
+
it("buckets duration-shaped keys, fingerprints queries (literal never kept)", () => {
|
|
107
|
+
const sig = deriveSignature("query_metrics", { query: "rate(http_requests_total[5m])", window: "1h", limit: 500 });
|
|
108
|
+
assert.match(sig.argShape.query, /m:http_requests_total/); // structural, not the literal
|
|
109
|
+
assert.ok(!sig.argShape.query.includes("[5m]"));
|
|
110
|
+
assert.equal(sig.argShape.window, "<=1h");
|
|
111
|
+
assert.equal(sig.argShape.limit, "<=1000");
|
|
112
|
+
});
|
|
113
|
+
it("handles booleans, empty strings, nested objects, and non-objects", () => {
|
|
114
|
+
const sig = deriveSignature("x", { flag: true, empty: "", nested: { a: 1 } });
|
|
115
|
+
assert.equal(sig.argShape.flag, "true");
|
|
116
|
+
assert.equal(sig.argShape.empty, "empty");
|
|
117
|
+
assert.equal(sig.argShape.nested, "object");
|
|
118
|
+
assert.deepEqual(deriveSignature("x", null), { argShape: {} });
|
|
119
|
+
assert.deepEqual(deriveSignature("x", "str"), { argShape: {} });
|
|
120
|
+
});
|
|
121
|
+
it("drops prototype-polluting arg keys (__proto__/constructor/prototype)", () => {
|
|
122
|
+
const sig = deriveSignature("x", JSON.parse('{"__proto__":"p","constructor":"c","prototype":"q","ok":5}'));
|
|
123
|
+
const keys = Object.keys(sig.argShape);
|
|
124
|
+
assert.ok(!keys.includes("__proto__"));
|
|
125
|
+
assert.ok(!keys.includes("constructor"));
|
|
126
|
+
assert.ok(!keys.includes("prototype"));
|
|
127
|
+
assert.equal(sig.argShape.ok, "<=10");
|
|
128
|
+
// prototype not polluted
|
|
129
|
+
assert.equal({}.p, undefined);
|
|
130
|
+
});
|
|
131
|
+
it("is deterministic", () => {
|
|
132
|
+
const a = deriveSignature("query_logs", { source: "s", service: "v", limit: 5, window: "5m" });
|
|
133
|
+
const b = deriveSignature("query_logs", { window: "5m", limit: 5, service: "v", source: "s" });
|
|
134
|
+
assert.deepEqual(a, b);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type Outcome = "ok" | "error";
|
|
2
|
+
export type Decision = "allow" | "would-block" | "blocked";
|
|
3
|
+
/** A single recorded tool-call observation (a signature, never raw args). */
|
|
4
|
+
export interface Observation {
|
|
5
|
+
ts: string;
|
|
6
|
+
seq: number;
|
|
7
|
+
principal: string;
|
|
8
|
+
auth: string;
|
|
9
|
+
tenant: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
service?: string;
|
|
13
|
+
namespace?: string;
|
|
14
|
+
argShape: Record<string, string>;
|
|
15
|
+
outcome: Outcome;
|
|
16
|
+
decision: Decision;
|
|
17
|
+
/** Deviation kind when the call fell outside the profile (dry-run/enforce). */
|
|
18
|
+
deviation?: string;
|
|
19
|
+
/** How many PII matches the redactor stripped from the args before shaping. */
|
|
20
|
+
redactions: number;
|
|
21
|
+
}
|
|
22
|
+
export type ObservationInput = Omit<Observation, "ts" | "seq"> & {
|
|
23
|
+
ts?: string;
|
|
24
|
+
};
|
|
25
|
+
export interface QueryOpts {
|
|
26
|
+
from?: string;
|
|
27
|
+
to?: string;
|
|
28
|
+
principal?: string;
|
|
29
|
+
tool?: string;
|
|
30
|
+
outcome?: Outcome;
|
|
31
|
+
decision?: Decision;
|
|
32
|
+
limit?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface InspectStoreOptions {
|
|
35
|
+
/** JSONL mirror path. When unset, the store is memory-only. */
|
|
36
|
+
file?: string;
|
|
37
|
+
/** Ring-buffer capacity (default 5000). */
|
|
38
|
+
cap?: number;
|
|
39
|
+
/** Clock seam for tests. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
/** Best-effort async appender seam for tests (defaults to fs append). */
|
|
42
|
+
appender?: (file: string, line: string) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export declare class InspectStore {
|
|
45
|
+
private ring;
|
|
46
|
+
private readonly cap;
|
|
47
|
+
private readonly file?;
|
|
48
|
+
private readonly now;
|
|
49
|
+
private readonly appender;
|
|
50
|
+
private seqCounter;
|
|
51
|
+
constructor(opts?: InspectStoreOptions);
|
|
52
|
+
/** Record one observation. Never throws. */
|
|
53
|
+
record(input: ObservationInput): Observation;
|
|
54
|
+
/** Newest-first query with optional filters. */
|
|
55
|
+
list(opts?: QueryOpts): Observation[];
|
|
56
|
+
/** All observations at or after `sinceMs` (epoch millis), oldest-first. */
|
|
57
|
+
since(sinceMs: number): Observation[];
|
|
58
|
+
/** Every observation currently retained (oldest-first). */
|
|
59
|
+
all(): Observation[];
|
|
60
|
+
get size(): number;
|
|
61
|
+
get persisted(): boolean;
|
|
62
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Inspect — observation store.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the audit-log storage pattern: an in-memory ring buffer (always on)
|
|
4
|
+
// plus an optional append-only JSONL mirror for durability. The ring is the
|
|
5
|
+
// query surface; the file is a write-only durable copy. Writes are best-effort
|
|
6
|
+
// and never throw — observation must never be able to slow down or fail a tool
|
|
7
|
+
// call.
|
|
8
|
+
import { appendFile } from "node:fs/promises";
|
|
9
|
+
const DEFAULT_CAP = 5000;
|
|
10
|
+
export class InspectStore {
|
|
11
|
+
ring = [];
|
|
12
|
+
cap;
|
|
13
|
+
file;
|
|
14
|
+
now;
|
|
15
|
+
appender;
|
|
16
|
+
seqCounter = 0;
|
|
17
|
+
constructor(opts = {}) {
|
|
18
|
+
this.cap = opts.cap && opts.cap > 0 ? opts.cap : DEFAULT_CAP;
|
|
19
|
+
this.file = opts.file;
|
|
20
|
+
this.now = opts.now ?? (() => Date.now());
|
|
21
|
+
this.appender = opts.appender ?? ((f, line) => appendFile(f, line));
|
|
22
|
+
}
|
|
23
|
+
/** Record one observation. Never throws. */
|
|
24
|
+
record(input) {
|
|
25
|
+
const obs = {
|
|
26
|
+
...input,
|
|
27
|
+
ts: input.ts ?? new Date(this.now()).toISOString(),
|
|
28
|
+
seq: ++this.seqCounter,
|
|
29
|
+
};
|
|
30
|
+
this.ring.push(obs);
|
|
31
|
+
if (this.ring.length > this.cap)
|
|
32
|
+
this.ring.shift();
|
|
33
|
+
if (this.file) {
|
|
34
|
+
// Fire-and-forget durable mirror; a failing disk never affects the call.
|
|
35
|
+
void this.appender(this.file, JSON.stringify(obs) + "\n").catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
return obs;
|
|
38
|
+
}
|
|
39
|
+
/** Newest-first query with optional filters. */
|
|
40
|
+
list(opts = {}) {
|
|
41
|
+
const limit = opts.limit && opts.limit > 0 ? Math.min(opts.limit, this.cap) : 200;
|
|
42
|
+
const out = [];
|
|
43
|
+
for (let i = this.ring.length - 1; i >= 0 && out.length < limit; i--) {
|
|
44
|
+
const e = this.ring[i];
|
|
45
|
+
if (opts.from && e.ts < opts.from)
|
|
46
|
+
continue;
|
|
47
|
+
if (opts.to && e.ts > opts.to)
|
|
48
|
+
continue;
|
|
49
|
+
if (opts.principal && e.principal !== opts.principal)
|
|
50
|
+
continue;
|
|
51
|
+
if (opts.tool && e.tool !== opts.tool)
|
|
52
|
+
continue;
|
|
53
|
+
if (opts.outcome && e.outcome !== opts.outcome)
|
|
54
|
+
continue;
|
|
55
|
+
if (opts.decision && e.decision !== opts.decision)
|
|
56
|
+
continue;
|
|
57
|
+
out.push(e);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** All observations at or after `sinceMs` (epoch millis), oldest-first. */
|
|
62
|
+
since(sinceMs) {
|
|
63
|
+
const cutoff = new Date(sinceMs).toISOString();
|
|
64
|
+
return this.ring.filter((e) => e.ts >= cutoff);
|
|
65
|
+
}
|
|
66
|
+
/** Every observation currently retained (oldest-first). */
|
|
67
|
+
all() {
|
|
68
|
+
return [...this.ring];
|
|
69
|
+
}
|
|
70
|
+
get size() {
|
|
71
|
+
return this.ring.length;
|
|
72
|
+
}
|
|
73
|
+
get persisted() {
|
|
74
|
+
return !!this.file;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { InspectStore } from "./store.js";
|
|
4
|
+
function mk(over = {}) {
|
|
5
|
+
return {
|
|
6
|
+
principal: "key:bot",
|
|
7
|
+
auth: "apikey",
|
|
8
|
+
tenant: "default",
|
|
9
|
+
tool: "query_logs",
|
|
10
|
+
argShape: {},
|
|
11
|
+
outcome: "ok",
|
|
12
|
+
decision: "allow",
|
|
13
|
+
redactions: 0,
|
|
14
|
+
...over,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe("InspectStore", () => {
|
|
18
|
+
it("assigns ts + monotonic seq and returns the stored observation", () => {
|
|
19
|
+
let t = 1_700_000_000_000;
|
|
20
|
+
const s = new InspectStore({ now: () => t });
|
|
21
|
+
const a = s.record(mk());
|
|
22
|
+
t += 1000;
|
|
23
|
+
const b = s.record(mk());
|
|
24
|
+
assert.equal(a.seq, 1);
|
|
25
|
+
assert.equal(b.seq, 2);
|
|
26
|
+
assert.ok(a.ts < b.ts);
|
|
27
|
+
assert.equal(s.size, 2);
|
|
28
|
+
});
|
|
29
|
+
it("rings out the oldest beyond cap", () => {
|
|
30
|
+
const s = new InspectStore({ cap: 3 });
|
|
31
|
+
for (let i = 0; i < 5; i++)
|
|
32
|
+
s.record(mk({ tool: `t${i}` }));
|
|
33
|
+
assert.equal(s.size, 3);
|
|
34
|
+
const tools = s.all().map((o) => o.tool);
|
|
35
|
+
assert.deepEqual(tools, ["t2", "t3", "t4"]);
|
|
36
|
+
});
|
|
37
|
+
it("filters by principal / tool / outcome / decision, newest-first", () => {
|
|
38
|
+
const s = new InspectStore();
|
|
39
|
+
s.record(mk({ principal: "a", tool: "query_logs", outcome: "ok" }));
|
|
40
|
+
s.record(mk({ principal: "b", tool: "query_metrics", outcome: "error" }));
|
|
41
|
+
s.record(mk({ principal: "a", tool: "query_metrics", decision: "would-block" }));
|
|
42
|
+
assert.equal(s.list({ principal: "a" }).length, 2);
|
|
43
|
+
assert.equal(s.list({ tool: "query_metrics" }).length, 2);
|
|
44
|
+
assert.equal(s.list({ outcome: "error" }).length, 1);
|
|
45
|
+
assert.equal(s.list({ decision: "would-block" }).length, 1);
|
|
46
|
+
// newest first
|
|
47
|
+
assert.equal(s.list({ principal: "a" })[0].decision, "would-block");
|
|
48
|
+
});
|
|
49
|
+
it("respects limit", () => {
|
|
50
|
+
const s = new InspectStore();
|
|
51
|
+
for (let i = 0; i < 10; i++)
|
|
52
|
+
s.record(mk());
|
|
53
|
+
assert.equal(s.list({ limit: 4 }).length, 4);
|
|
54
|
+
});
|
|
55
|
+
it("since() returns observations at/after a cutoff", () => {
|
|
56
|
+
let t = 1_700_000_000_000;
|
|
57
|
+
const s = new InspectStore({ now: () => t });
|
|
58
|
+
s.record(mk({ tool: "old" }));
|
|
59
|
+
t += 60_000;
|
|
60
|
+
const cut = t;
|
|
61
|
+
t += 1000;
|
|
62
|
+
s.record(mk({ tool: "new" }));
|
|
63
|
+
const recent = s.since(cut);
|
|
64
|
+
assert.equal(recent.length, 1);
|
|
65
|
+
assert.equal(recent[0].tool, "new");
|
|
66
|
+
});
|
|
67
|
+
it("mirrors to the file appender when configured; never throws on append failure", () => {
|
|
68
|
+
const lines = [];
|
|
69
|
+
const s = new InspectStore({ file: "inspect-store-test.jsonl", appender: async (_f, l) => { lines.push(l); } });
|
|
70
|
+
assert.equal(s.persisted, true);
|
|
71
|
+
s.record(mk({ tool: "logged" }));
|
|
72
|
+
// appender is fire-and-forget; the line is queued synchronously here
|
|
73
|
+
assert.equal(lines.length, 1);
|
|
74
|
+
assert.match(lines[0], /"tool":"logged"/);
|
|
75
|
+
const boom = new InspectStore({ file: "inspect-store-test.jsonl", appender: async () => { throw new Error("disk full"); } });
|
|
76
|
+
assert.doesNotThrow(() => boom.record(mk()));
|
|
77
|
+
});
|
|
78
|
+
});
|
package/dist/metrics/self.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export declare const connectorCalls: Counter<"type" | "source" | "outcome" | "op
|
|
|
6
6
|
export declare const apiRequests: Counter<"status" | "route" | "method">;
|
|
7
7
|
export declare const mcpActiveSessions: Gauge<string>;
|
|
8
8
|
export declare const auditDlqDepth: Gauge<string>;
|
|
9
|
+
export declare const inspectEvents: Counter<"tool" | "outcome" | "decision">;
|
|
10
|
+
/** Record one inspection observation in the self-metrics registry. Never throws. */
|
|
11
|
+
export declare function recordInspectEvent(tool: string, outcome: string, decision: string): void;
|
|
9
12
|
/**
|
|
10
13
|
* Wrap a (potentially async) tool handler to record call count + latency.
|
|
11
14
|
* Outcome is "ok" or "error" — never throws on its own.
|
package/dist/metrics/self.js
CHANGED
|
@@ -48,6 +48,25 @@ export const auditDlqDepth = new Gauge({
|
|
|
48
48
|
help: "Number of audit entries waiting in the webhook-sink dead-letter queue.",
|
|
49
49
|
registers: [selfRegistry],
|
|
50
50
|
});
|
|
51
|
+
// Inspect: one counter per recorded tool-call observation, labelled by tool,
|
|
52
|
+
// outcome (ok/error) and decision (allow/would-block/blocked). Lets operators
|
|
53
|
+
// graph deviation/block rates straight from Prometheus alongside the in-app
|
|
54
|
+
// Flows view.
|
|
55
|
+
export const inspectEvents = new Counter({
|
|
56
|
+
name: "obsmcp_inspect_events_total",
|
|
57
|
+
help: "Inspect observations by tool, outcome and policy decision.",
|
|
58
|
+
labelNames: ["tool", "outcome", "decision"],
|
|
59
|
+
registers: [selfRegistry],
|
|
60
|
+
});
|
|
61
|
+
/** Record one inspection observation in the self-metrics registry. Never throws. */
|
|
62
|
+
export function recordInspectEvent(tool, outcome, decision) {
|
|
63
|
+
try {
|
|
64
|
+
inspectEvents.inc({ tool, outcome, decision });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* metrics must never affect the call path */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
51
70
|
/**
|
|
52
71
|
* Wrap a (potentially async) tool handler to record call count + latency.
|
|
53
72
|
* Outcome is "ok" or "error" — never throws on its own.
|
|
@@ -43,3 +43,10 @@ export declare function tenantFromClaim(claims: Record<string, unknown>, claimPa
|
|
|
43
43
|
* cognitive load stays low. Invalid tenant strings normalise to
|
|
44
44
|
* DEFAULT_TENANT silently. */
|
|
45
45
|
export declare function parseKeyTenants(raw: string | undefined): Map<string, string>;
|
|
46
|
+
/** Is the deployment ACTIVELY multi-tenant — i.e. configured to place any
|
|
47
|
+
* identity in a NON-default tenant? True when an OIDC tenant claim is set,
|
|
48
|
+
* or OMCP_KEY_TENANTS maps a credential to a tenant other than `default`.
|
|
49
|
+
* The single-tenant default (no claim, no non-default key mapping) returns
|
|
50
|
+
* false, so the open-source path stays free. This is the predicate the boot
|
|
51
|
+
* sequence uses to decide whether the `tenancy` entitlement is required. */
|
|
52
|
+
export declare function isMultiTenantConfigured(oidcTenantClaim: string | undefined, keyTenantsEnv: string | undefined): boolean;
|
package/dist/tenancy/context.js
CHANGED
|
@@ -95,3 +95,18 @@ export function parseKeyTenants(raw) {
|
|
|
95
95
|
}
|
|
96
96
|
return out;
|
|
97
97
|
}
|
|
98
|
+
/** Is the deployment ACTIVELY multi-tenant — i.e. configured to place any
|
|
99
|
+
* identity in a NON-default tenant? True when an OIDC tenant claim is set,
|
|
100
|
+
* or OMCP_KEY_TENANTS maps a credential to a tenant other than `default`.
|
|
101
|
+
* The single-tenant default (no claim, no non-default key mapping) returns
|
|
102
|
+
* false, so the open-source path stays free. This is the predicate the boot
|
|
103
|
+
* sequence uses to decide whether the `tenancy` entitlement is required. */
|
|
104
|
+
export function isMultiTenantConfigured(oidcTenantClaim, keyTenantsEnv) {
|
|
105
|
+
if (oidcTenantClaim && oidcTenantClaim.trim().length > 0)
|
|
106
|
+
return true;
|
|
107
|
+
for (const t of parseKeyTenants(keyTenantsEnv).values()) {
|
|
108
|
+
if (t && t !== DEFAULT_TENANT)
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { DEFAULT_TENANT, MAX_TENANT_LENGTH, normaliseTenant, tenantFromClaim, parseKeyTenants, } from "./context.js";
|
|
3
|
+
import { DEFAULT_TENANT, MAX_TENANT_LENGTH, normaliseTenant, tenantFromClaim, parseKeyTenants, isMultiTenantConfigured, } from "./context.js";
|
|
4
4
|
test("normaliseTenant — happy paths", () => {
|
|
5
5
|
assert.equal(normaliseTenant("acme"), "acme");
|
|
6
6
|
assert.equal(normaliseTenant("ACME"), "acme", "lowercases");
|
|
@@ -70,3 +70,20 @@ test("parseKeyTenants — undefined / empty returns empty map", () => {
|
|
|
70
70
|
assert.equal(parseKeyTenants(undefined).size, 0);
|
|
71
71
|
assert.equal(parseKeyTenants("").size, 0);
|
|
72
72
|
});
|
|
73
|
+
test("isMultiTenantConfigured — single-tenant default is false (OSS path stays free)", () => {
|
|
74
|
+
// No OIDC tenant claim, no key-tenant mapping → single-tenant.
|
|
75
|
+
assert.equal(isMultiTenantConfigured(undefined, undefined), false);
|
|
76
|
+
assert.equal(isMultiTenantConfigured("", ""), false);
|
|
77
|
+
// Mappings that only ever resolve to `default` are NOT multi-tenant.
|
|
78
|
+
assert.equal(isMultiTenantConfigured("", "agent=default;ci=default"), false);
|
|
79
|
+
// Invalid tenant strings normalise to `default`, so they don't trip it.
|
|
80
|
+
assert.equal(isMultiTenantConfigured("", "agent=!!!bad!!!"), false);
|
|
81
|
+
});
|
|
82
|
+
test("isMultiTenantConfigured — true once a non-default tenant is configured", () => {
|
|
83
|
+
// An OIDC tenant claim alone activates multi-tenancy.
|
|
84
|
+
assert.equal(isMultiTenantConfigured("tid", undefined), true);
|
|
85
|
+
assert.equal(isMultiTenantConfigured(" tenant ", undefined), true);
|
|
86
|
+
// A non-default key→tenant mapping activates it.
|
|
87
|
+
assert.equal(isMultiTenantConfigured("", "agent=acme"), true);
|
|
88
|
+
assert.equal(isMultiTenantConfigured("", "agent=default;ci=bigco"), true);
|
|
89
|
+
});
|