@thotischner/observability-mcp 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/auth/policy/loader.js +1 -1
  2. package/dist/auth/rbac.d.ts +1 -1
  3. package/dist/auth/rbac.js +3 -1
  4. package/dist/auth/rbac.test.js +5 -3
  5. package/dist/conformance/inspect-e2e.test.d.ts +1 -0
  6. package/dist/conformance/inspect-e2e.test.js +104 -0
  7. package/dist/connectors/loader.js +30 -0
  8. package/dist/connectors/loader.test.js +11 -0
  9. package/dist/enterprise-gate.d.ts +28 -0
  10. package/dist/enterprise-gate.js +51 -0
  11. package/dist/enterprise-gate.test.js +21 -1
  12. package/dist/index.js +285 -6
  13. package/dist/inspect/enforcer.d.ts +19 -0
  14. package/dist/inspect/enforcer.js +69 -0
  15. package/dist/inspect/enforcer.test.d.ts +1 -0
  16. package/dist/inspect/enforcer.test.js +76 -0
  17. package/dist/inspect/graph.d.ts +33 -0
  18. package/dist/inspect/graph.js +0 -0
  19. package/dist/inspect/graph.test.d.ts +1 -0
  20. package/dist/inspect/graph.test.js +74 -0
  21. package/dist/inspect/index.d.ts +8 -0
  22. package/dist/inspect/index.js +13 -0
  23. package/dist/inspect/mode.d.ts +20 -0
  24. package/dist/inspect/mode.js +57 -0
  25. package/dist/inspect/mode.test.d.ts +1 -0
  26. package/dist/inspect/mode.test.js +53 -0
  27. package/dist/inspect/profile-store.d.ts +42 -0
  28. package/dist/inspect/profile-store.js +139 -0
  29. package/dist/inspect/profile-store.test.d.ts +1 -0
  30. package/dist/inspect/profile-store.test.js +82 -0
  31. package/dist/inspect/profile.d.ts +51 -0
  32. package/dist/inspect/profile.js +111 -0
  33. package/dist/inspect/profile.test.d.ts +1 -0
  34. package/dist/inspect/profile.test.js +96 -0
  35. package/dist/inspect/recorder.d.ts +42 -0
  36. package/dist/inspect/recorder.js +72 -0
  37. package/dist/inspect/recorder.test.d.ts +1 -0
  38. package/dist/inspect/recorder.test.js +112 -0
  39. package/dist/inspect/signature.d.ts +32 -0
  40. package/dist/inspect/signature.js +200 -0
  41. package/dist/inspect/signature.test.d.ts +1 -0
  42. package/dist/inspect/signature.test.js +136 -0
  43. package/dist/inspect/store.d.ts +62 -0
  44. package/dist/inspect/store.js +76 -0
  45. package/dist/inspect/store.test.d.ts +1 -0
  46. package/dist/inspect/store.test.js +78 -0
  47. package/dist/metrics/self.d.ts +3 -0
  48. package/dist/metrics/self.js +19 -0
  49. package/dist/tenancy/context.d.ts +7 -0
  50. package/dist/tenancy/context.js +15 -0
  51. package/dist/tenancy/context.test.js +18 -1
  52. package/dist/ui/index.html +742 -0
  53. package/package.json +3 -2
@@ -0,0 +1,96 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { deriveProfile, evaluateCall, ruleId } from "./profile.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("deriveProfile", () => {
21
+ it("creates one suggested rule per (subject,tool) with unioned constraints", () => {
22
+ const rules = deriveProfile([
23
+ obs({ principal: "alice", tool: "query_logs", service: "pay", argShape: { window: "<=1h" } }),
24
+ obs({ principal: "alice", tool: "query_logs", service: "order", argShape: { window: "<=5m" } }),
25
+ obs({ principal: "bob", tool: "query_metrics", source: "prom" }),
26
+ ]);
27
+ assert.equal(rules.length, 2);
28
+ const a = rules.find((r) => r.id === ruleId("alice", "query_logs"));
29
+ assert.equal(a.status, "suggested");
30
+ assert.deepEqual(a.constraints.service, ["order", "pay"]); // sorted union
31
+ assert.deepEqual(a.constraints.argShape.window, ["<=1h", "<=5m"]);
32
+ assert.equal(a.provenance.learnedFrom, 2);
33
+ const b = rules.find((r) => r.id === ruleId("bob", "query_metrics"));
34
+ assert.deepEqual(b.constraints.source, ["prom"]);
35
+ });
36
+ it("is idempotent and never overwrites a human decision", () => {
37
+ let rules = deriveProfile([obs({ principal: "a", tool: "t", service: "s1" })]);
38
+ rules = rules.map((r) => ({ ...r, status: "accepted" }));
39
+ // New traffic with a NEW service value arrives + re-derive.
40
+ const after = deriveProfile([obs({ principal: "a", tool: "t", service: "s2" })], rules);
41
+ const r = after.find((x) => x.id === ruleId("a", "t"));
42
+ assert.equal(r.status, "accepted"); // untouched
43
+ assert.deepEqual(r.constraints.service, ["s1"]); // NOT widened to include s2
44
+ });
45
+ it("does not resurrect a rejected rule", () => {
46
+ const rejected = [{
47
+ id: ruleId("a", "t"), subject: "a", tool: "t", constraints: {}, status: "rejected",
48
+ provenance: { learnedFrom: 1, firstSeen: "x", lastSeen: "x" },
49
+ }];
50
+ const after = deriveProfile([obs({ principal: "a", tool: "t" })], rejected);
51
+ assert.equal(after.find((r) => r.id === ruleId("a", "t")).status, "rejected");
52
+ });
53
+ it("refreshes a still-suggested rule from new traffic", () => {
54
+ let rules = deriveProfile([obs({ principal: "a", tool: "t", service: "s1" })]);
55
+ rules = deriveProfile([obs({ principal: "a", tool: "t", service: "s2" })], rules);
56
+ assert.deepEqual(rules.find((r) => r.id === ruleId("a", "t")).constraints.service, ["s2"]);
57
+ });
58
+ });
59
+ describe("evaluateCall", () => {
60
+ const rules = [{
61
+ id: ruleId("alice", "query_logs"), subject: "alice", tool: "query_logs",
62
+ constraints: { service: ["pay", "order"], argShape: { window: ["<=1h", "<=5m"] } },
63
+ status: "accepted", provenance: { learnedFrom: 5, firstSeen: "x", lastSeen: "y" },
64
+ }];
65
+ const call = (over = {}) => ({ principal: "alice", tool: "query_logs", argShape: {}, ...over });
66
+ it("allows a call within an accepted rule", () => {
67
+ const r = evaluateCall(call({ service: "pay", argShape: { window: "<=1h" } }), rules);
68
+ assert.equal(r.verdict, "allow");
69
+ });
70
+ it("flags a new resource value outside the rule", () => {
71
+ const r = evaluateCall(call({ service: "secret-svc" }), rules);
72
+ assert.equal(r.verdict, "deviation");
73
+ assert.equal(r.kind, "new-resource");
74
+ assert.match(r.detail, /service=secret-svc/);
75
+ });
76
+ it("flags an arg bucket outside the learned range", () => {
77
+ const r = evaluateCall(call({ service: "pay", argShape: { window: ">1d" } }), rules);
78
+ assert.equal(r.kind, "arg-out-of-range");
79
+ });
80
+ it("flags a known principal reaching for a new tool", () => {
81
+ const r = evaluateCall(call({ tool: "enrich_ips" }), rules);
82
+ assert.equal(r.kind, "new-tool");
83
+ });
84
+ it("flags a wholly-new principal", () => {
85
+ const r = evaluateCall(call({ principal: "mallory" }), rules);
86
+ assert.equal(r.kind, "new-principal");
87
+ });
88
+ it("ignores suggested/rejected rules (only accepted gate)", () => {
89
+ const sugg = [{ ...rules[0], status: "suggested" }];
90
+ assert.equal(evaluateCall(call({ service: "pay", argShape: { window: "<=1h" } }), sugg).kind, "new-principal");
91
+ });
92
+ it("honours a wildcard subject rule", () => {
93
+ const wild = [{ ...rules[0], subject: "*" }];
94
+ assert.equal(evaluateCall(call({ principal: "anyone", service: "pay", argShape: { window: "<=5m" } }), wild).verdict, "allow");
95
+ });
96
+ });
@@ -0,0 +1,42 @@
1
+ import type { HookRegistration } from "../sdk/hooks.js";
2
+ import type { Decision, InspectStore, Outcome } from "./store.js";
3
+ import type { ModeController } from "./mode.js";
4
+ /** An MCP tool result signals failure via `isError: true`. */
5
+ export declare function isErrorResult(result: unknown): boolean;
6
+ /** Coarse auth-kind inference from the principal (HookContext has no auth). */
7
+ export declare function authKind(principal: string): string;
8
+ /** A profile evaluation seam — given a derived call signature, returns the
9
+ * verdict against the accepted profile. Absent in pure observe mode. */
10
+ export interface ProfileEvaluator {
11
+ evaluate(call: {
12
+ principal: string;
13
+ tool: string;
14
+ source?: string;
15
+ service?: string;
16
+ namespace?: string;
17
+ argShape: Record<string, string>;
18
+ }): {
19
+ verdict: "allow" | "deviation";
20
+ kind?: string;
21
+ };
22
+ }
23
+ export interface RecorderOptions {
24
+ /** Metrics seam — called once per recorded observation. */
25
+ onEvent?: (e: {
26
+ tool: string;
27
+ outcome: Outcome;
28
+ decision: Decision;
29
+ }) => void;
30
+ /** Accepted-profile evaluator. Consulted only when mode.evaluating
31
+ * (dry-run / enforce). When a call deviates it is recorded `would-block`
32
+ * — this post-invoke recorder never blocks (enforce blocking is a separate
33
+ * pre-invoke hook). */
34
+ evaluator?: ProfileEvaluator;
35
+ }
36
+ /**
37
+ * Build the recorder hook (tool_post_invoke, permissive). Records every call
38
+ * while the mode is recording. In dry-run / enforce it also evaluates the call
39
+ * against the accepted profile and records a `would-block` decision + deviation
40
+ * kind for calls outside the profile — but never blocks (it runs post-invoke).
41
+ */
42
+ export declare function createInspectRecorder(store: InspectStore, mode: ModeController, opts?: RecorderOptions): HookRegistration;
@@ -0,0 +1,72 @@
1
+ // Inspect — the observe recorder.
2
+ //
3
+ // Registers as a `tool_post_invoke` hook in PERMISSIVE mode so it can never
4
+ // block a tool call (and even if it threw, the hook registry swallows it for a
5
+ // permissive hook). It redacts the args, derives a signature, and appends one
6
+ // observation. Pure side-effect; returns allow:true always.
7
+ import { redactValue } from "../policy/redact.js";
8
+ import { deriveSignature } from "./signature.js";
9
+ /** An MCP tool result signals failure via `isError: true`. */
10
+ export function isErrorResult(result) {
11
+ return !!(result && typeof result === "object" && result.isError === true);
12
+ }
13
+ /** Coarse auth-kind inference from the principal (HookContext has no auth). */
14
+ export function authKind(principal) {
15
+ return principal === "anonymous" ? "anonymous" : "apikey";
16
+ }
17
+ /**
18
+ * Build the recorder hook (tool_post_invoke, permissive). Records every call
19
+ * while the mode is recording. In dry-run / enforce it also evaluates the call
20
+ * against the accepted profile and records a `would-block` decision + deviation
21
+ * kind for calls outside the profile — but never blocks (it runs post-invoke).
22
+ */
23
+ export function createInspectRecorder(store, mode, opts = {}) {
24
+ const handler = (ctx, payload) => {
25
+ try {
26
+ if (!mode.recording)
27
+ return { allow: true };
28
+ const red = redactValue(payload.args);
29
+ const sig = deriveSignature(ctx.target, red.value);
30
+ const outcome = isErrorResult(payload.result) ? "error" : "ok";
31
+ let decision = "allow";
32
+ let deviation;
33
+ if (mode.evaluating && opts.evaluator) {
34
+ const ev = opts.evaluator.evaluate({
35
+ principal: ctx.principal, tool: ctx.target,
36
+ source: sig.source, service: sig.service, namespace: sig.namespace,
37
+ argShape: sig.argShape,
38
+ });
39
+ if (ev.verdict === "deviation") {
40
+ decision = "would-block";
41
+ deviation = ev.kind;
42
+ }
43
+ }
44
+ store.record({
45
+ principal: ctx.principal,
46
+ auth: authKind(ctx.principal),
47
+ tenant: ctx.tenant,
48
+ tool: ctx.target,
49
+ source: sig.source,
50
+ service: sig.service,
51
+ namespace: sig.namespace,
52
+ argShape: sig.argShape,
53
+ outcome,
54
+ decision,
55
+ deviation,
56
+ redactions: red.totalMatches,
57
+ });
58
+ opts.onEvent?.({ tool: ctx.target, outcome, decision });
59
+ }
60
+ catch {
61
+ // Observation must never affect the call path.
62
+ }
63
+ return { allow: true };
64
+ };
65
+ return {
66
+ pluginName: "inspect-recorder",
67
+ kind: "tool_post_invoke",
68
+ priority: 5,
69
+ mode: "permissive",
70
+ handler,
71
+ };
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createInspectRecorder, isErrorResult, authKind } from "./recorder.js";
4
+ import { InspectStore } from "./store.js";
5
+ import { ModeController } from "./mode.js";
6
+ const ctx = (over = {}) => ({
7
+ principal: "key:bot",
8
+ tenant: "default",
9
+ kind: "tool_post_invoke",
10
+ target: "query_logs",
11
+ ...over,
12
+ });
13
+ describe("recorder helpers", () => {
14
+ it("isErrorResult detects MCP error envelopes", () => {
15
+ assert.equal(isErrorResult({ isError: true }), true);
16
+ assert.equal(isErrorResult({ isError: false }), false);
17
+ assert.equal(isErrorResult({ content: [] }), false);
18
+ assert.equal(isErrorResult(null), false);
19
+ });
20
+ it("authKind infers from principal", () => {
21
+ assert.equal(authKind("anonymous"), "anonymous");
22
+ assert.equal(authKind("key:bot"), "apikey");
23
+ });
24
+ });
25
+ describe("createInspectRecorder", () => {
26
+ it("registers as a permissive tool_post_invoke hook", () => {
27
+ const reg = createInspectRecorder(new InspectStore(), new ModeController("observe"));
28
+ assert.equal(reg.kind, "tool_post_invoke");
29
+ assert.equal(reg.mode, "permissive");
30
+ assert.equal(reg.pluginName, "inspect-recorder");
31
+ });
32
+ it("records an observation with a derived signature and always allows", async () => {
33
+ const store = new InspectStore();
34
+ const reg = createInspectRecorder(store, new ModeController("observe"));
35
+ const r = await reg.handler(ctx({ target: "query_logs" }), { args: { source: "prom-eu", service: "pay", query: "rate(x[5m])", window: "1h" }, result: { content: [] } });
36
+ assert.deepEqual(r, { allow: true });
37
+ assert.equal(store.size, 1);
38
+ const o = store.all()[0];
39
+ assert.equal(o.tool, "query_logs");
40
+ assert.equal(o.source, "prom-eu");
41
+ assert.equal(o.service, "pay");
42
+ assert.equal(o.argShape.window, "<=1h");
43
+ assert.match(o.argShape.query, /m:x/); // fingerprint, literal never stored
44
+ assert.equal(o.decision, "allow");
45
+ assert.equal(o.outcome, "ok");
46
+ });
47
+ it("redacts secrets out of args before shaping (no PII leaks into the store)", async () => {
48
+ const store = new InspectStore();
49
+ const reg = createInspectRecorder(store, new ModeController("observe"));
50
+ await reg.handler(ctx(), { args: { note: "contact ops@example.com token AKIAIOSFODNN7EXAMPLE" }, result: {} });
51
+ const o = store.all()[0];
52
+ assert.ok(o.redactions >= 1, "redactor ran");
53
+ // the value collapses to "present"; literal never persisted regardless
54
+ assert.equal(o.argShape.note, "present");
55
+ assert.ok(!JSON.stringify(o).includes("AKIA"));
56
+ });
57
+ it("marks error outcomes from isError results", async () => {
58
+ const store = new InspectStore();
59
+ const reg = createInspectRecorder(store, new ModeController("observe"));
60
+ await reg.handler(ctx(), { args: {}, result: { isError: true } });
61
+ assert.equal(store.all()[0].outcome, "error");
62
+ });
63
+ it("records nothing when mode is off", async () => {
64
+ const store = new InspectStore();
65
+ const reg = createInspectRecorder(store, new ModeController("off"));
66
+ await reg.handler(ctx(), { args: {}, result: {} });
67
+ assert.equal(store.size, 0);
68
+ });
69
+ it("never throws and still allows even if shaping blows up", async () => {
70
+ const store = new InspectStore();
71
+ const reg = createInspectRecorder(store, new ModeController("observe"));
72
+ // circular args would throw in JSON paths; redactValue handles objects but
73
+ // we assert the contract: handler resolves allow:true regardless.
74
+ const circular = {};
75
+ circular.self = circular;
76
+ const r = await reg.handler(ctx(), { args: circular, result: {} });
77
+ assert.deepEqual(r, { allow: true });
78
+ });
79
+ it("dry-run: records would-block + deviation kind for a profile deviation", async () => {
80
+ const store = new InspectStore();
81
+ const evaluator = { evaluate: () => ({ verdict: "deviation", kind: "new-resource" }) };
82
+ const reg = createInspectRecorder(store, new ModeController("dryrun"), { evaluator });
83
+ await reg.handler(ctx({ target: "query_logs" }), { args: { service: "novel" }, result: {} });
84
+ const o = store.all()[0];
85
+ assert.equal(o.decision, "would-block");
86
+ assert.equal(o.deviation, "new-resource");
87
+ });
88
+ it("dry-run: records allow when the call is within profile", async () => {
89
+ const store = new InspectStore();
90
+ const evaluator = { evaluate: () => ({ verdict: "allow" }) };
91
+ const reg = createInspectRecorder(store, new ModeController("dryrun"), { evaluator });
92
+ await reg.handler(ctx(), { args: {}, result: {} });
93
+ assert.equal(store.all()[0].decision, "allow");
94
+ });
95
+ it("observe mode never consults the evaluator (always allow)", async () => {
96
+ const store = new InspectStore();
97
+ let consulted = false;
98
+ const evaluator = { evaluate: () => { consulted = true; return { verdict: "deviation", kind: "new-tool" }; } };
99
+ const reg = createInspectRecorder(store, new ModeController("observe"), { evaluator });
100
+ await reg.handler(ctx(), { args: {}, result: {} });
101
+ assert.equal(store.all()[0].decision, "allow");
102
+ assert.equal(consulted, false);
103
+ });
104
+ it("fires the onEvent metrics seam", async () => {
105
+ const seen = [];
106
+ const reg = createInspectRecorder(new InspectStore(), new ModeController("observe"), {
107
+ onEvent: (e) => seen.push(e),
108
+ });
109
+ await reg.handler(ctx({ target: "enrich_ips" }), { args: { ips: ["1.2.3.4"] }, result: {} });
110
+ assert.deepEqual(seen, [{ tool: "enrich_ips", outcome: "ok", decision: "allow" }]);
111
+ });
112
+ });
@@ -0,0 +1,32 @@
1
+ /** The resource dimensions we treat as first-class (real values kept). */
2
+ export declare const RESOURCE_KEYS: readonly ["source", "service", "namespace"];
3
+ export type ResourceKey = (typeof RESOURCE_KEYS)[number];
4
+ export interface Signature {
5
+ source?: string;
6
+ service?: string;
7
+ namespace?: string;
8
+ /** key → coarse bucket label (e.g. window → "<=1h", ips → "n=11-100"). */
9
+ argShape: Record<string, string>;
10
+ }
11
+ /** Bucket a count/length into coarse, order-of-magnitude bands. */
12
+ export declare function countBucket(n: number): string;
13
+ /** Parse a Prometheus/Loki-style duration (e.g. "5m", "1h30m", "90s", "2d")
14
+ * to seconds. Returns null when it isn't a duration string. */
15
+ export declare function durationToSeconds(s: string): number | null;
16
+ /** Bucket a duration (seconds) into coarse time bands. */
17
+ export declare function durationBucket(s: string): string;
18
+ /** Bucket a bare number into small / medium / large bands. */
19
+ export declare function numBucket(n: number): string;
20
+ /**
21
+ * Structural fingerprint of a PromQL/LogQL query — a deterministic, bounded
22
+ * signal of *which* metric(s), function(s) and label key(s) it touches, WITHOUT
23
+ * keeping the literal query. Lets a profile rule distinguish "query_metrics on
24
+ * metric X" from "...on metric Y". Heuristic (not a full parser) but stable.
25
+ * Returns "present" when nothing structural is extractable, "empty" for blank.
26
+ */
27
+ export declare function queryFingerprint(q: string): string;
28
+ /**
29
+ * Derive a Signature from a tool name + its (redacted) arguments.
30
+ * Deterministic and pure — same input always yields the same signature.
31
+ */
32
+ export declare function deriveSignature(_tool: string, args: unknown): Signature;
@@ -0,0 +1,200 @@
1
+ // Inspect — argument-shape derivation.
2
+ //
3
+ // Turns a tool call's (already-redacted) arguments into a coarse, learnable
4
+ // *signature*: the resource dimensions it targeted (source / service /
5
+ // namespace — the equivalent of AppArmor's file paths) plus a bucketed shape
6
+ // of the remaining scalar args. We deliberately never keep literal argument
7
+ // values (especially free-text PromQL/LogQL queries) — only their shape — so
8
+ // the store stays small, privacy-preserving, and the profile generalises
9
+ // instead of memorising one exact call.
10
+ /** The resource dimensions we treat as first-class (real values kept). */
11
+ export const RESOURCE_KEYS = ["source", "service", "namespace"];
12
+ /** Bucket a count/length into coarse, order-of-magnitude bands. */
13
+ export function countBucket(n) {
14
+ if (!Number.isFinite(n) || n < 0)
15
+ return "?";
16
+ if (n <= 1)
17
+ return "1";
18
+ if (n <= 10)
19
+ return "2-10";
20
+ if (n <= 100)
21
+ return "11-100";
22
+ if (n <= 1000)
23
+ return "101-1000";
24
+ return ">1000";
25
+ }
26
+ /** Parse a Prometheus/Loki-style duration (e.g. "5m", "1h30m", "90s", "2d")
27
+ * to seconds. Returns null when it isn't a duration string. */
28
+ export function durationToSeconds(s) {
29
+ const str = s.trim();
30
+ if (!/^\d+(?:\.\d+)?(?:ms|s|m|h|d|w|y)(?:\d+(?:\.\d+)?(?:ms|s|m|h|d|w|y))*$/.test(str)) {
31
+ return null;
32
+ }
33
+ const unit = { ms: 0.001, s: 1, m: 60, h: 3600, d: 86400, w: 604800, y: 31536000 };
34
+ let total = 0;
35
+ const re = /(\d+(?:\.\d+)?)(ms|s|m|h|d|w|y)/g;
36
+ let match;
37
+ while ((match = re.exec(str)) !== null) {
38
+ total += parseFloat(match[1]) * unit[match[2]];
39
+ }
40
+ return total;
41
+ }
42
+ /** Bucket a duration (seconds) into coarse time bands. */
43
+ export function durationBucket(s) {
44
+ const secs = durationToSeconds(s);
45
+ if (secs === null)
46
+ return "other";
47
+ if (secs <= 300)
48
+ return "<=5m";
49
+ if (secs <= 3600)
50
+ return "<=1h";
51
+ if (secs <= 86400)
52
+ return "<=1d";
53
+ return ">1d";
54
+ }
55
+ /** Bucket a bare number into small / medium / large bands. */
56
+ export function numBucket(n) {
57
+ if (!Number.isFinite(n))
58
+ return "?";
59
+ if (n <= 10)
60
+ return "<=10";
61
+ if (n <= 100)
62
+ return "<=100";
63
+ if (n <= 1000)
64
+ return "<=1000";
65
+ return ">1000";
66
+ }
67
+ /** Keys whose string value should be treated as a duration when parseable. */
68
+ const DURATION_KEY = /window|range|lookback|step|interval|since|duration|period|timeout/i;
69
+ /** Arg keys carrying a PromQL/LogQL expression — fingerprinted, not kept literal. */
70
+ const QUERY_KEY = /^(query|expr|promql|logql)$/i;
71
+ const PROMQL_KEYWORDS = new Set([
72
+ "by", "without", "on", "ignoring", "group_left", "group_right", "offset",
73
+ "bool", "and", "or", "unless", "keep_metric_names", "start", "end", "inf", "nan",
74
+ ]);
75
+ // Aggregation operators can appear as `sum by (x) (expr)` — i.e. NOT immediately
76
+ // before "(" — so they need recognising by name, not just by a trailing paren.
77
+ const PROMQL_AGG = new Set([
78
+ "sum", "min", "max", "avg", "group", "stddev", "stdvar",
79
+ "count", "count_values", "bottomk", "topk", "quantile",
80
+ ]);
81
+ /**
82
+ * Structural fingerprint of a PromQL/LogQL query — a deterministic, bounded
83
+ * signal of *which* metric(s), function(s) and label key(s) it touches, WITHOUT
84
+ * keeping the literal query. Lets a profile rule distinguish "query_metrics on
85
+ * metric X" from "...on metric Y". Heuristic (not a full parser) but stable.
86
+ * Returns "present" when nothing structural is extractable, "empty" for blank.
87
+ */
88
+ export function queryFingerprint(q) {
89
+ if (!q || !q.trim())
90
+ return "empty";
91
+ // Bound the input first — query args are attacker-controlled and run on the
92
+ // recorder/enforcer hot path; a huge string must not cost real CPU. 4 KB is
93
+ // ample for a structural signal; truncation stays deterministic.
94
+ const capped = q.length > 4096 ? q.slice(0, 4096) : q;
95
+ // Drop string literals so label values / quoted text never look like metrics.
96
+ const s = capped.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, '""');
97
+ const funcs = new Set();
98
+ const labels = new Set();
99
+ const metrics = new Set();
100
+ const exclude = new Set();
101
+ // Functions: an identifier immediately followed by "(" (excluding keywords
102
+ // like `by(`), plus any aggregation operator by name (e.g. `sum by (x)`).
103
+ for (const m of s.matchAll(/([A-Za-z_]\w{0,127})\s*\(/g)) {
104
+ if (!PROMQL_KEYWORDS.has(m[1].toLowerCase()))
105
+ funcs.add(m[1]);
106
+ }
107
+ for (const m of s.matchAll(/[A-Za-z_]\w*/g)) {
108
+ if (PROMQL_AGG.has(m[0].toLowerCase()))
109
+ funcs.add(m[0]);
110
+ }
111
+ // Grouping labels: by/without/on/ignoring/group_* ( … ) — not metrics.
112
+ for (const m of s.matchAll(/\b(?:by|without|on|ignoring|group_left|group_right)\s*\(([^)]*)\)/gi)) {
113
+ for (const id of m[1].match(/[A-Za-z_]\w*/g) ?? []) {
114
+ labels.add(id);
115
+ exclude.add(id);
116
+ }
117
+ }
118
+ // Selector label keys: inside { … } before = / != / =~ / !~.
119
+ for (const br of s.matchAll(/\{([^}]*)\}/g)) {
120
+ for (const m of br[1].matchAll(/([A-Za-z_]\w*)\s*(?:=~|!~|=|!=)/g)) {
121
+ labels.add(m[1]);
122
+ exclude.add(m[1]);
123
+ }
124
+ }
125
+ // Metrics: identifiers (PromQL allows ':') that aren't funcs/keywords/labels,
126
+ // not a range-duration fragment ([5m] → "m", preceded by a digit).
127
+ for (const m of s.matchAll(/([A-Za-z_:][\w:]*)/g)) {
128
+ const idx = m.index ?? 0;
129
+ const id = m[1];
130
+ const prev = idx > 0 ? s[idx - 1] : "";
131
+ if (/\d/.test(prev))
132
+ continue;
133
+ const nxt = (s.slice(idx + id.length).match(/^\s*(.)/) ?? [])[1] ?? "";
134
+ if (nxt === "(")
135
+ continue;
136
+ if (funcs.has(id) || exclude.has(id) || PROMQL_KEYWORDS.has(id.toLowerCase()))
137
+ continue;
138
+ metrics.add(id);
139
+ }
140
+ const cap = (set) => [...set].sort().slice(0, 8).join(",");
141
+ const parts = [];
142
+ if (funcs.size)
143
+ parts.push("f:" + cap(funcs));
144
+ if (metrics.size)
145
+ parts.push("m:" + cap(metrics));
146
+ if (labels.size)
147
+ parts.push("l:" + cap(labels));
148
+ return parts.length ? parts.join(" ") : "present";
149
+ }
150
+ /**
151
+ * Derive a Signature from a tool name + its (redacted) arguments.
152
+ * Deterministic and pure — same input always yields the same signature.
153
+ */
154
+ export function deriveSignature(_tool, args) {
155
+ const sig = { argShape: {} };
156
+ if (!args || typeof args !== "object" || Array.isArray(args))
157
+ return sig;
158
+ const a = args;
159
+ for (const k of RESOURCE_KEYS) {
160
+ const v = a[k];
161
+ if (typeof v === "string" && v.trim())
162
+ sig[k] = v.trim();
163
+ }
164
+ for (const [k, v] of Object.entries(a)) {
165
+ if (RESOURCE_KEYS.includes(k))
166
+ continue;
167
+ // Never let a prototype-polluting arg name into the shape map (keys come
168
+ // from arbitrary tool-call arguments — js/remote-property-injection).
169
+ if (k === "__proto__" || k === "constructor" || k === "prototype")
170
+ continue;
171
+ if (Array.isArray(v)) {
172
+ sig.argShape[k] = "n=" + countBucket(v.length);
173
+ }
174
+ else if (typeof v === "number") {
175
+ sig.argShape[k] = numBucket(v);
176
+ }
177
+ else if (typeof v === "boolean") {
178
+ sig.argShape[k] = v ? "true" : "false";
179
+ }
180
+ else if (typeof v === "string") {
181
+ if (DURATION_KEY.test(k) && durationToSeconds(v) !== null) {
182
+ sig.argShape[k] = durationBucket(v);
183
+ }
184
+ else if (QUERY_KEY.test(k)) {
185
+ // Query args get a structural fingerprint (which metric/func/labels),
186
+ // not the literal — lets rules distinguish queries by shape.
187
+ sig.argShape[k] = queryFingerprint(v);
188
+ }
189
+ else {
190
+ // Other free-text values (ids, names) collapse to "present" — never
191
+ // the literal.
192
+ sig.argShape[k] = v.trim() ? "present" : "empty";
193
+ }
194
+ }
195
+ else if (v && typeof v === "object") {
196
+ sig.argShape[k] = "object";
197
+ }
198
+ }
199
+ return sig;
200
+ }
@@ -0,0 +1 @@
1
+ export {};