@thotischner/observability-mcp 3.3.2 → 3.5.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.
@@ -16,6 +16,11 @@
16
16
  * # via the bypass_redaction tool arg. Off by default
17
17
  * # for every key — pair with the redaction:bypass
18
18
  * # RBAC permission for the management-plane angle.
19
+ * OMCP_KEY_RAW_QUERY="agent,ci"
20
+ * # optional comma-separated list of key NAMES allowed to
21
+ * # run raw_query even when the global OMCP_RAW_QUERY
22
+ * # capability is off. Effective gate = global OR per-key,
23
+ * # so it only widens; off by default for every key.
19
24
  * OMCP_KEY_TENANTS="agent=acme;ci=bigco"
20
25
  * # optional per-key tenant assignment. Unlisted keys
21
26
  * # land in the "default" tenant — identical to the
@@ -42,6 +47,11 @@ export interface Credential {
42
47
  * to explicitly set `bypass_redaction: true` in the tool args —
43
48
  * this flag only authorises it; it never auto-disables redaction. */
44
49
  bypassRedaction?: boolean;
50
+ /** True when the operator opted this credential into running `raw_query`
51
+ * even with the global OMCP_RAW_QUERY capability off. Configured via
52
+ * OMCP_KEY_RAW_QUERY. The effective gate is `global OR per-credential` —
53
+ * it only widens access, never narrows a globally-enabled deployment. */
54
+ allowRawQuery?: boolean;
45
55
  /** Tenant this credential belongs to. Omitted → DEFAULT_TENANT. */
46
56
  tenant?: string;
47
57
  /** Product id this credential is bound to. When set, /mcp tools/list
@@ -16,6 +16,11 @@
16
16
  * # via the bypass_redaction tool arg. Off by default
17
17
  * # for every key — pair with the redaction:bypass
18
18
  * # RBAC permission for the management-plane angle.
19
+ * OMCP_KEY_RAW_QUERY="agent,ci"
20
+ * # optional comma-separated list of key NAMES allowed to
21
+ * # run raw_query even when the global OMCP_RAW_QUERY
22
+ * # capability is off. Effective gate = global OR per-key,
23
+ * # so it only widens; off by default for every key.
19
24
  * OMCP_KEY_TENANTS="agent=acme;ci=bigco"
20
25
  * # optional per-key tenant assignment. Unlisted keys
21
26
  * # land in the "default" tenant — identical to the
@@ -76,6 +81,7 @@ export function loadCredentials(env = process.env) {
76
81
  return [];
77
82
  const keySources = parseKeySources(env.OMCP_KEY_SOURCES);
78
83
  const bypassNames = parseBypassSet(env.OMCP_KEY_BYPASS_REDACTION);
84
+ const rawQueryNames = parseBypassSet(env.OMCP_KEY_RAW_QUERY);
79
85
  const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
80
86
  const keyProducts = parseKeyProducts(env.OMCP_KEY_PRODUCTS);
81
87
  const creds = [];
@@ -90,6 +96,7 @@ export function loadCredentials(env = process.env) {
90
96
  token,
91
97
  allowedSources: keySources.get(name),
92
98
  bypassRedaction: bypassNames.has(name) || undefined,
99
+ allowRawQuery: rawQueryNames.has(name) || undefined,
93
100
  tenant: keyTenants.get(name) || undefined,
94
101
  productId: keyProducts.get(name) || undefined,
95
102
  });
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
11
11
  it("parses name:token and bare token", () => {
12
12
  const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
13
13
  assert.equal(creds.length, 2);
14
- assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, tenant: undefined, productId: undefined });
14
+ assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined });
15
15
  assert.equal(creds[1].name, "key");
16
16
  assert.equal(creds[1].token, "tok_bare");
17
17
  });
@@ -39,6 +39,21 @@ describe("single-tenant auth primitive", () => {
39
39
  for (const c of creds)
40
40
  assert.equal(c.bypassRedaction, undefined);
41
41
  });
42
+ it("parses OMCP_KEY_RAW_QUERY → flags only the listed names", () => {
43
+ const creds = loadCredentials({
44
+ OMCP_API_KEYS: "agent:tok1,ci:tok2,unprivileged:tok3",
45
+ OMCP_KEY_RAW_QUERY: "agent, ci",
46
+ });
47
+ assert.equal(creds.find((c) => c.name === "agent")?.allowRawQuery, true);
48
+ assert.equal(creds.find((c) => c.name === "ci")?.allowRawQuery, true);
49
+ // Unlisted keys MUST be undefined (not false) so it only widens.
50
+ assert.equal(creds.find((c) => c.name === "unprivileged")?.allowRawQuery, undefined);
51
+ });
52
+ it("OMCP_KEY_RAW_QUERY absent → no key may raw_query per-credential (global flag still applies)", () => {
53
+ const creds = loadCredentials({ OMCP_API_KEYS: "agent:tok1,ci:tok2" });
54
+ for (const c of creds)
55
+ assert.equal(c.allowRawQuery, undefined);
56
+ });
42
57
  it("parses OMCP_KEY_TENANTS → assigns tenant to named keys; unlisted stays undefined (default)", () => {
43
58
  const creds = loadCredentials({
44
59
  OMCP_API_KEYS: "agent:tok1,ci:tok2,nobody:tok3",
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, existsSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { dirname, join } from "node:path";
6
6
  import { validatePassword, passwordPolicyFromEnv, passwordPolicyDisabledFromEnv, DEFAULT_PASSWORD_POLICY, COMMON_PASSWORD_DENYLIST, } from "./password-policy.js";
@@ -89,14 +89,23 @@ test("passwordPolicyDisabledFromEnv recognises truthy values", () => {
89
89
  assert.equal(passwordPolicyDisabledFromEnv({ OMCP_PASSWORD_POLICY_DISABLED: "false" }), false);
90
90
  assert.equal(passwordPolicyDisabledFromEnv({}), false);
91
91
  });
92
- test("CLI hash-password.mjs stays in sync with the canonical policy", () => {
92
+ // The CLI lives at the repo root (scripts/hash-password.mjs), reached via
93
+ // ../../../scripts from mcp-server/src/auth. Under a partial mount that only
94
+ // mounts mcp-server/ (e.g. the docker-first `docker run -v "$(pwd)/mcp-server:/app"`
95
+ // unit-test command) the repo-root scripts/ dir isn't present. Skip there
96
+ // rather than emit a spurious ENOENT failure — but in CI the full repo is
97
+ // checked out, so a miss is a real move/rename and must still fail loudly.
98
+ const _here = dirname(fileURLToPath(import.meta.url));
99
+ const _cliPath = join(_here, "..", "..", "..", "scripts", "hash-password.mjs");
100
+ const _cliSyncSkip = existsSync(_cliPath) || process.env.CI
101
+ ? {}
102
+ : { skip: "scripts/hash-password.mjs not reachable from this mount (run from the repo root or in CI)" };
103
+ test("CLI hash-password.mjs stays in sync with the canonical policy", _cliSyncSkip, () => {
93
104
  // The CLI deliberately duplicates the policy to stay dependency-free
94
105
  // (same precedent as the scrypt params). This guard fails loudly if the
95
106
  // two drift: every canonical denylist entry + the defaults must appear
96
107
  // verbatim in the script. (__dirname → mcp-server/src/auth)
97
- const here = dirname(fileURLToPath(import.meta.url));
98
- const cliPath = join(here, "..", "..", "..", "scripts", "hash-password.mjs");
99
- const cli = readFileSync(cliPath, "utf8");
108
+ const cli = readFileSync(_cliPath, "utf8");
100
109
  for (const entry of COMMON_PASSWORD_DENYLIST) {
101
110
  assert.ok(cli.includes(`"${entry}"`), `CLI denylist is missing "${entry}"`);
102
111
  }
package/dist/context.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface RequestContext {
23
23
  * tool call ALSO sets `bypass_redaction: true` in its args. Default
24
24
  * false. Configured via OMCP_KEY_BYPASS_REDACTION. */
25
25
  allowBypassRedaction?: boolean;
26
+ /** When true, this credential may run `raw_query` even if the global
27
+ * OMCP_RAW_QUERY capability is off. Per-credential gating (configured via
28
+ * OMCP_KEY_RAW_QUERY) — the effective gate is `global OR per-credential`,
29
+ * so a global enable still works and this only widens, never narrows.
30
+ * Default false. */
31
+ allowRawQuery?: boolean;
26
32
  /** Tenant the request operates in. ALWAYS set — defaults to
27
33
  * "default" for anonymous principals + missing-tenant credentials,
28
34
  * preserving the single-namespace behaviour of pre-E7 deployments. */
@@ -50,6 +56,7 @@ export declare function defaultContext(opts?: {
50
56
  /** Context for an authenticated API-key principal. */
51
57
  export declare function principalContext(principalId: string, allowedSources?: string[], opts?: {
52
58
  allowBypassRedaction?: boolean;
59
+ allowRawQuery?: boolean;
53
60
  tenant?: string;
54
61
  allowedTools?: string[];
55
62
  }): RequestContext;
package/dist/context.js CHANGED
@@ -24,6 +24,7 @@ export function principalContext(principalId, allowedSources, opts = {}) {
24
24
  auth: "apikey",
25
25
  allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
26
26
  allowBypassRedaction: opts.allowBypassRedaction || undefined,
27
+ allowRawQuery: opts.allowRawQuery || undefined,
27
28
  tenant: normaliseTenant(opts.tenant),
28
29
  allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
29
30
  correlationId: randomUUID(),
@@ -29,6 +29,12 @@ test("principalContext — passes allowedTools through; empty array → undefine
29
29
  const ctx3 = principalContext("agent");
30
30
  assert.equal(ctx3.allowedTools, undefined);
31
31
  });
32
+ test("principalContext — allowRawQuery passes through, off by default (R4 per-credential raw_query)", () => {
33
+ assert.equal(principalContext("agent").allowRawQuery, undefined);
34
+ assert.equal(principalContext("agent", undefined, {}).allowRawQuery, undefined);
35
+ assert.equal(principalContext("agent", undefined, { allowRawQuery: false }).allowRawQuery, undefined);
36
+ assert.equal(principalContext("agent", undefined, { allowRawQuery: true }).allowRawQuery, true);
37
+ });
32
38
  test("defaultContext — no allowedTools (anonymous sees every tool, back-compat)", () => {
33
39
  const ctx = defaultContext();
34
40
  assert.equal(ctx.allowedTools, undefined);
@@ -13,13 +13,25 @@ export declare function parseCidr(cidr: string): {
13
13
  end: number;
14
14
  prefix: number;
15
15
  } | null;
16
+ /** Parse an IPv6 string to a 128-bit BigInt, or null if invalid. Handles
17
+ * `::` zero-compression and a trailing IPv4-mapped tail (`::ffff:1.2.3.4`). */
18
+ export declare function ipv6ToBigInt(ip: string): bigint | null;
19
+ /** Parse an IPv6 CIDR (or bare IPv6 = /128) to an inclusive BigInt range. */
20
+ export declare function parseCidr6(cidr: string): {
21
+ start: bigint;
22
+ end: bigint;
23
+ prefix: number;
24
+ } | null;
16
25
  export declare class IpEnrichmentDataset {
17
26
  private ranges;
18
- /** Rows that couldn't be parsed (bad CIDR, IPv6, malformed) — surfaced for diagnostics. */
27
+ private ranges6;
28
+ /** Rows that couldn't be parsed (bad CIDR, malformed) — surfaced for diagnostics. */
19
29
  readonly skipped: number;
20
30
  readonly size: number;
21
31
  private constructor();
22
32
  static fromCsv(text: string): IpEnrichmentDataset;
23
- /** Look up an IPv4 string. Returns the most specific matching row, or null. */
33
+ /** Look up an IPv4 or IPv6 string. Returns the most specific matching row, or null. */
24
34
  lookup(ip: string): IpEnrichment | null;
35
+ private lookup4;
36
+ private lookup6;
25
37
  }
@@ -9,9 +9,9 @@
9
9
  // 1.2.3.0/24,US,Ashburn,AS14618,Example Cloud,true
10
10
  // 203.0.113.5,DE,Berlin,AS3320,Example ISP,false
11
11
  //
12
- // - `network` is an IPv4 CIDR (or a bare IPv4, treated as /32). IPv6 rows are
13
- // skipped (logged by the caller) IPv4 covers the access-log case the
14
- // report was about; IPv6 can follow.
12
+ // - `network` is an IPv4 or IPv6 CIDR (or a bare address, treated as /32 or
13
+ // /128). Both families are supported; IPv4 uses fast 32-bit integer ranges,
14
+ // IPv6 uses 128-bit BigInt ranges. IPv4-mapped IPv6 (`::ffff:1.2.3.4`) parses.
15
15
  // - Remaining columns are optional; an empty cell is omitted from the result.
16
16
  // - `hosting` is the "is this a datacenter / hosting / proxy range" flag — the
17
17
  // signal that separates real humans from bots/scanners/VPN-exit-nodes. Parsed
@@ -50,20 +50,94 @@ export function parseCidr(cidr) {
50
50
  const end = (start + (hostBits === 32 ? 0xffffffff : (1 << hostBits) - 1)) >>> 0;
51
51
  return { start, end, prefix };
52
52
  }
53
+ /** Parse an IPv6 string to a 128-bit BigInt, or null if invalid. Handles
54
+ * `::` zero-compression and a trailing IPv4-mapped tail (`::ffff:1.2.3.4`). */
55
+ export function ipv6ToBigInt(ip) {
56
+ let s = ip.trim();
57
+ if (s === "" || s.includes(":::"))
58
+ return null;
59
+ // A trailing embedded IPv4 (e.g. ::ffff:1.2.3.4) → two hextets.
60
+ const lastColon = s.lastIndexOf(":");
61
+ if (s.slice(lastColon + 1).includes(".")) {
62
+ const v4 = ipv4ToInt(s.slice(lastColon + 1));
63
+ if (v4 === null)
64
+ return null;
65
+ const hi = (v4 >>> 16) & 0xffff;
66
+ const lo = v4 & 0xffff;
67
+ s = s.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
68
+ }
69
+ const halves = s.split("::");
70
+ if (halves.length > 2)
71
+ return null; // at most one "::"
72
+ const parseGroups = (part) => {
73
+ if (part === "")
74
+ return [];
75
+ const groups = part.split(":");
76
+ const out = [];
77
+ for (const g of groups) {
78
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g))
79
+ return null;
80
+ out.push(parseInt(g, 16));
81
+ }
82
+ return out;
83
+ };
84
+ let hextets;
85
+ if (halves.length === 2) {
86
+ const left = parseGroups(halves[0]);
87
+ const right = parseGroups(halves[1]);
88
+ if (left === null || right === null)
89
+ return null;
90
+ const fill = 8 - left.length - right.length;
91
+ if (fill < 1)
92
+ return null; // "::" must stand for at least one zero group
93
+ hextets = [...left, ...Array(fill).fill(0), ...right];
94
+ }
95
+ else {
96
+ const all = parseGroups(s);
97
+ if (all === null)
98
+ return null;
99
+ hextets = all;
100
+ }
101
+ if (hextets.length !== 8)
102
+ return null;
103
+ let n = 0n;
104
+ for (const h of hextets)
105
+ n = (n << 16n) | BigInt(h);
106
+ return n;
107
+ }
108
+ /** Parse an IPv6 CIDR (or bare IPv6 = /128) to an inclusive BigInt range. */
109
+ export function parseCidr6(cidr) {
110
+ const [addr, prefixStr] = cidr.trim().split("/");
111
+ const base = ipv6ToBigInt(addr);
112
+ if (base === null)
113
+ return null;
114
+ const prefix = prefixStr === undefined ? 128 : Number(prefixStr);
115
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 128)
116
+ return null;
117
+ const hostBits = BigInt(128 - prefix);
118
+ const full = (1n << 128n) - 1n;
119
+ const mask = prefix === 0 ? 0n : (full << hostBits) & full;
120
+ const start = base & mask;
121
+ const end = start | ((1n << hostBits) - 1n);
122
+ return { start, end, prefix };
123
+ }
53
124
  export class IpEnrichmentDataset {
54
125
  ranges = [];
55
- /** Rows that couldn't be parsed (bad CIDR, IPv6, malformed) — surfaced for diagnostics. */
126
+ ranges6 = [];
127
+ /** Rows that couldn't be parsed (bad CIDR, malformed) — surfaced for diagnostics. */
56
128
  skipped;
57
129
  size;
58
- constructor(ranges, skipped) {
130
+ constructor(ranges, ranges6, skipped) {
59
131
  // Sort by start asc; lookup picks the most specific (largest prefix)
60
132
  // containing range so nested/overlapping rows resolve deterministically.
61
133
  this.ranges = ranges.sort((a, b) => a.start - b.start || a.end - b.end);
134
+ this.ranges6 = ranges6.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.end < b.end ? -1 : a.end > b.end ? 1 : 0));
62
135
  this.skipped = skipped;
63
- this.size = ranges.length;
136
+ this.size = ranges.length + ranges6.length;
64
137
  }
65
138
  static fromCsv(text) {
66
139
  const ranges = [];
140
+ const ranges6 = [];
67
141
  let skipped = 0;
68
142
  for (const rawLine of text.split(/\r?\n/)) {
69
143
  const line = rawLine.trim();
@@ -72,11 +146,6 @@ export class IpEnrichmentDataset {
72
146
  const cells = line.split(",").map((c) => c.trim());
73
147
  if (cells[0].toLowerCase() === "network")
74
148
  continue; // header
75
- const r = parseCidr(cells[0]);
76
- if (!r) {
77
- skipped++;
78
- continue;
79
- }
80
149
  const data = {};
81
150
  if (cells[1])
82
151
  data.country = cells[1];
@@ -89,12 +158,31 @@ export class IpEnrichmentDataset {
89
158
  if (cells[5] !== undefined && cells[5] !== "") {
90
159
  data.hosting = ["true", "1", "yes"].includes(cells[5].toLowerCase());
91
160
  }
92
- ranges.push({ start: r.start, end: r.end, prefix: r.prefix, data });
161
+ // Route by family: a ':' in the network cell means IPv6.
162
+ if (cells[0].includes(":")) {
163
+ const r6 = parseCidr6(cells[0]);
164
+ if (!r6) {
165
+ skipped++;
166
+ continue;
167
+ }
168
+ ranges6.push({ start: r6.start, end: r6.end, prefix: r6.prefix, data });
169
+ }
170
+ else {
171
+ const r = parseCidr(cells[0]);
172
+ if (!r) {
173
+ skipped++;
174
+ continue;
175
+ }
176
+ ranges.push({ start: r.start, end: r.end, prefix: r.prefix, data });
177
+ }
93
178
  }
94
- return new IpEnrichmentDataset(ranges, skipped);
179
+ return new IpEnrichmentDataset(ranges, ranges6, skipped);
95
180
  }
96
- /** Look up an IPv4 string. Returns the most specific matching row, or null. */
181
+ /** Look up an IPv4 or IPv6 string. Returns the most specific matching row, or null. */
97
182
  lookup(ip) {
183
+ return ip.includes(":") ? this.lookup6(ip) : this.lookup4(ip);
184
+ }
185
+ lookup4(ip) {
98
186
  const n = ipv4ToInt(ip);
99
187
  if (n === null)
100
188
  return null;
@@ -110,4 +198,17 @@ export class IpEnrichmentDataset {
110
198
  }
111
199
  return best ? { ...best.data } : null;
112
200
  }
201
+ lookup6(ip) {
202
+ const n = ipv6ToBigInt(ip);
203
+ if (n === null)
204
+ return null;
205
+ let best = null;
206
+ for (const r of this.ranges6) {
207
+ if (r.start > n)
208
+ break; // sorted by start asc
209
+ if (n <= r.end && (best === null || r.prefix > best.prefix))
210
+ best = r;
211
+ }
212
+ return best ? { ...best.data } : null;
213
+ }
113
214
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { ipv4ToInt, parseCidr, IpEnrichmentDataset } from "./ip-dataset.js";
3
+ import { ipv4ToInt, parseCidr, ipv6ToBigInt, parseCidr6, IpEnrichmentDataset } from "./ip-dataset.js";
4
4
  describe("ipv4ToInt", () => {
5
5
  it("parses valid IPv4", () => {
6
6
  assert.equal(ipv4ToInt("0.0.0.0"), 0);
@@ -49,13 +49,13 @@ describe("IpEnrichmentDataset.fromCsv + lookup", () => {
49
49
  "10.0.0.0/8,US,,AS100,Example Cloud,true",
50
50
  "10.1.2.0/24,US,Ashburn,AS100,Example Cloud Edge,true",
51
51
  "203.0.113.5,DE,Berlin,AS3320,Example ISP,false",
52
- "2001:db8::/32,XX,,,,", // IPv6 skipped
52
+ "2001:db8::/32,XX,,,,", // IPv6 now parsed, not skipped
53
53
  "garbage-row",
54
54
  ].join("\n");
55
- it("parses rows, skips header/comments/blank, counts skipped", () => {
55
+ it("parses rows (v4 + v6), skips header/comments/blank, counts skipped", () => {
56
56
  const ds = IpEnrichmentDataset.fromCsv(csv);
57
- assert.equal(ds.size, 3); // 3 valid IPv4 rows
58
- assert.equal(ds.skipped, 2); // IPv6 + garbage
57
+ assert.equal(ds.size, 4); // 3 IPv4 + 1 IPv6 row
58
+ assert.equal(ds.skipped, 1); // only the garbage row
59
59
  });
60
60
  it("returns the most specific (longest-prefix) match", () => {
61
61
  const ds = IpEnrichmentDataset.fromCsv(csv);
@@ -83,3 +83,79 @@ describe("IpEnrichmentDataset.fromCsv + lookup", () => {
83
83
  assert.equal(ds.lookup("not-an-ip"), null);
84
84
  });
85
85
  });
86
+ describe("ipv6ToBigInt", () => {
87
+ it("parses a full address", () => {
88
+ assert.equal(ipv6ToBigInt("2001:0db8:0000:0000:0000:0000:0000:0001"), 0x20010db8000000000000000000000001n);
89
+ });
90
+ it("expands :: zero-compression", () => {
91
+ assert.equal(ipv6ToBigInt("2001:db8::1"), 0x20010db8000000000000000000000001n);
92
+ assert.equal(ipv6ToBigInt("::1"), 1n);
93
+ assert.equal(ipv6ToBigInt("::"), 0n);
94
+ assert.equal(ipv6ToBigInt("ff02::"), 0xff020000000000000000000000000000n);
95
+ });
96
+ it("parses an IPv4-mapped tail", () => {
97
+ // ::ffff:1.2.3.4 → the v4 lives in the low 32 bits with ffff above it
98
+ assert.equal(ipv6ToBigInt("::ffff:1.2.3.4"), 0x00000000000000000000ffff01020304n);
99
+ });
100
+ it("rejects malformed input", () => {
101
+ assert.equal(ipv6ToBigInt("2001:db8::1::2"), null); // two ::
102
+ assert.equal(ipv6ToBigInt("gggg::1"), null);
103
+ assert.equal(ipv6ToBigInt("1.2.3.4"), null); // v4 is not v6
104
+ assert.equal(ipv6ToBigInt("12345::"), null); // hextet too long
105
+ assert.equal(ipv6ToBigInt(""), null);
106
+ });
107
+ });
108
+ describe("parseCidr6", () => {
109
+ it("parses a /32 to its inclusive range", () => {
110
+ const r = parseCidr6("2001:db8::/32");
111
+ assert.equal(r?.prefix, 32);
112
+ assert.equal(r?.start, 0x20010db8000000000000000000000000n);
113
+ assert.equal(r?.end, 0x20010db8ffffffffffffffffffffffffn);
114
+ });
115
+ it("treats a bare address as /128", () => {
116
+ const r = parseCidr6("2001:db8::1");
117
+ assert.equal(r?.prefix, 128);
118
+ assert.equal(r?.start, r?.end);
119
+ });
120
+ it("handles /0 (whole space)", () => {
121
+ const r = parseCidr6("::/0");
122
+ assert.equal(r?.start, 0n);
123
+ assert.equal(r?.end, (1n << 128n) - 1n);
124
+ });
125
+ it("rejects bad prefixes / addresses", () => {
126
+ assert.equal(parseCidr6("2001:db8::/129"), null);
127
+ assert.equal(parseCidr6("nope::/32"), null);
128
+ });
129
+ });
130
+ describe("IpEnrichmentDataset IPv6 lookup", () => {
131
+ const csv = [
132
+ "network,country,city,asn,org,hosting",
133
+ "2001:db8::/32,US,,AS100,Example Cloud,true",
134
+ "2001:db8:1::/48,US,Ashburn,AS100,Example Cloud Edge,true",
135
+ "2606:4700::/32,US,,AS13335,Example CDN,true",
136
+ "10.0.0.0/8,DE,Berlin,AS3320,Example ISP,false", // v4 row alongside
137
+ ].join("\n");
138
+ it("returns the most specific v6 match", () => {
139
+ const ds = IpEnrichmentDataset.fromCsv(csv);
140
+ const hit = ds.lookup("2001:db8:1::abcd"); // inside both /32 and /48
141
+ assert.equal(hit?.city, "Ashburn");
142
+ assert.equal(hit?.org, "Example Cloud Edge");
143
+ });
144
+ it("falls back to the broader v6 range", () => {
145
+ const ds = IpEnrichmentDataset.fromCsv(csv);
146
+ const hit = ds.lookup("2001:db8:9::1"); // only in /32
147
+ assert.equal(hit?.asn, "AS100");
148
+ assert.equal(hit?.city, undefined);
149
+ });
150
+ it("keeps v4 and v6 lookups independent", () => {
151
+ const ds = IpEnrichmentDataset.fromCsv(csv);
152
+ assert.equal(ds.lookup("10.1.2.3")?.country, "DE"); // v4 path
153
+ assert.equal(ds.lookup("2606:4700::1")?.org, "Example CDN"); // v6 path
154
+ assert.equal(ds.lookup("2607:f8b0::1"), null); // unmatched v6
155
+ });
156
+ it("normalises an IPv4-mapped query against a v4 row? no — :: form hits v6 table only", () => {
157
+ const ds = IpEnrichmentDataset.fromCsv(csv);
158
+ // A ':'-bearing query goes to the v6 table; it won't match the 10.0.0.0/8 v4 row.
159
+ assert.equal(ds.lookup("::ffff:10.1.2.3"), null);
160
+ });
161
+ });
package/dist/index.js CHANGED
@@ -555,7 +555,7 @@ async function main() {
555
555
  .describe("Optional escape hatch: a verbatim PromQL expression, run as-is over the range — for ad-hoc queries the curated `metric` catalog can't express (any series, any function, broken down by any label). When set, `metric`/`service`/`groupBy`/`labels` are ignored. DISABLED by default; the operator must enable the raw-query capability (OMCP_RAW_QUERY=on) or the call is refused. Still tenant-scoped and source-allow-listed."),
556
556
  }, { title: "Query Metrics", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (args) => {
557
557
  await enforceEntitledAccess(ctx, { tool: "query_metrics", source: args?.source, service: args?.service });
558
- const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED }));
558
+ const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED || ctx.allowRawQuery === true }));
559
559
  return chargeTokenBudget(result, ctx, "query_metrics");
560
560
  });
561
561
  registerTool("query_logs", [
@@ -623,7 +623,7 @@ async function main() {
623
623
  .describe("Optional escape hatch: a verbatim LogQL log query, run as-is — for selectors/pipelines the curated params can't express. When set, `service`/`labels`/`level`/`query` are ignored and it is mutually exclusive with `aggregate` (express aggregation in the LogQL itself). DISABLED by default; the operator must enable the raw-query capability (OMCP_RAW_QUERY=on) or the call is refused. Redaction still applies to the returned log lines."),
624
624
  }, { title: "Query Logs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (args) => {
625
625
  await enforceEntitledAccess(ctx, { tool: "query_logs", source: args?.source, service: args?.service });
626
- const result = await withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED }));
626
+ const result = await withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED || ctx.allowRawQuery === true }));
627
627
  // Redact PII / secrets from the log payload before it crosses the
628
628
  // MCP boundary into the agent's context. Per-call bypass kicks in
629
629
  // only when BOTH (a) the credential is OMCP_KEY_BYPASS_REDACTION
@@ -3367,6 +3367,7 @@ async function main() {
3367
3367
  }
3368
3368
  return principalContext(cred.name, cred.allowedSources, {
3369
3369
  allowBypassRedaction: cred.bypassRedaction,
3370
+ allowRawQuery: cred.allowRawQuery,
3370
3371
  tenant: cred.tenant,
3371
3372
  allowedTools,
3372
3373
  });
@@ -3607,6 +3608,7 @@ async function main() {
3607
3608
  return {
3608
3609
  ctx: principalContext(cred.name, cred.allowedSources, {
3609
3610
  allowBypassRedaction: cred.bypassRedaction,
3611
+ allowRawQuery: cred.allowRawQuery,
3610
3612
  tenant: cred.tenant,
3611
3613
  allowedTools,
3612
3614
  }),
@@ -45,6 +45,13 @@ export interface PostmortemInput {
45
45
  traces: TraceSummary[];
46
46
  /** Optional log-error summary lines, e.g. ["payment-service: 412 5xx in window"]. */
47
47
  logHighlights?: string[];
48
+ /** Optional custom report template (issue: v3.3 candidate). When set, the
49
+ * markdown body is rendered by interpolating `{{placeholder}}` tokens
50
+ * instead of the built-in layout. Unset → the default report (unchanged).
51
+ * Available tokens: service, window, from, to, tenant, synopsis, timeline,
52
+ * blastRadius, signals, traces, logHighlights, followUps. An unknown token
53
+ * is left verbatim so a typo is visible rather than silently dropped. */
54
+ template?: string;
48
55
  }
49
56
  export interface PostmortemReport {
50
57
  service: string;
@@ -81,3 +88,20 @@ export interface PostmortemReport {
81
88
  /** Synthesise one report from already-fetched primitives. Pure
82
89
  * compute — no I/O. */
83
90
  export declare function synthesizePostmortem(input: PostmortemInput): PostmortemReport;
91
+ type RenderCtx = {
92
+ input: PostmortemInput;
93
+ timeline: PostmortemReport["sections"]["timeline"];
94
+ contributingSignals: PostmortemReport["sections"]["contributingSignals"];
95
+ peakNode: BlastRadiusNode | undefined;
96
+ peakScore: number;
97
+ errorTraces: number;
98
+ blastSize: number;
99
+ followUps: string[];
100
+ synopsis: string;
101
+ };
102
+ /** Build the `{{token}}` → markdown-block map for a custom template. */
103
+ export declare function buildTemplateVars(ctx: RenderCtx): Record<string, string>;
104
+ /** Interpolate `{{token}}` placeholders. An unknown token is left verbatim so
105
+ * a typo is visible in the output rather than silently producing a blank. */
106
+ export declare function renderTemplate(template: string, vars: Record<string, string>): string;
107
+ export {};
@@ -22,7 +22,7 @@ export function synthesizePostmortem(input) {
22
22
  const blastSize = input.blastRadius.nodes.length;
23
23
  const followUps = inferFollowUps(input, { peakScore, errorTraces, blastSize });
24
24
  const synopsis = synopsisFor(input, peakScore, errorTraces, blastSize);
25
- const markdown = renderMarkdown({
25
+ const renderCtx = {
26
26
  input,
27
27
  timeline,
28
28
  contributingSignals,
@@ -32,7 +32,12 @@ export function synthesizePostmortem(input) {
32
32
  blastSize,
33
33
  followUps,
34
34
  synopsis,
35
- });
35
+ };
36
+ // Default layout unless the operator supplied a custom template — the
37
+ // default path is byte-for-byte unchanged.
38
+ const markdown = input.template
39
+ ? renderTemplate(input.template, buildTemplateVars(renderCtx))
40
+ : renderMarkdown(renderCtx);
36
41
  return {
37
42
  service: input.service,
38
43
  window: input.window,
@@ -203,3 +208,51 @@ function renderMarkdown(ctx) {
203
208
  // could approach MB without the slice() caps above.
204
209
  return lines.join("\n");
205
210
  }
211
+ /** Build the `{{token}}` → markdown-block map for a custom template. */
212
+ export function buildTemplateVars(ctx) {
213
+ const { input, timeline, contributingSignals, peakNode, errorTraces, followUps, synopsis } = ctx;
214
+ const timelineBlock = timeline.length === 0
215
+ ? "_No anomaly samples in this window._"
216
+ : ["| ts | service | score | severity | method |", "|---|---|---|---|---|",
217
+ ...timeline.slice(0, 20).map((t) => `| \`${t.ts}\` | \`${t.service}\` | ${t.score} | ${t.severity} | ${t.method} |`),
218
+ ...(timeline.length > 20 ? [`| … | _${timeline.length - 20} more rows_ | | | |`] : [])].join("\n");
219
+ const brLines = [];
220
+ brLines.push(peakNode ? `Root node: **\`${peakNode.name}\`** (\`${peakNode.kind}\`).` : "_Topology snapshot empty._");
221
+ if (input.blastRadius.nodes.length > 0) {
222
+ brLines.push("", "| node | kind |", "|---|---|", ...input.blastRadius.nodes.slice(0, 30).map((n) => `| \`${n.name}\`${n.root ? " *(root)*" : ""} | \`${n.kind}\` |`));
223
+ }
224
+ brLines.push("", `Edges in radius: **${input.blastRadius.edges.length}**.`);
225
+ const blastRadiusBlock = brLines.join("\n");
226
+ const signalsBlock = contributingSignals.length === 0
227
+ ? "_No anomaly samples to rank._"
228
+ : ["| signal | samples | mean score |", "|---|---|---|",
229
+ ...contributingSignals.slice(0, 10).map((s) => `| \`${s.signal}\` | ${s.count} | ${s.meanScore} |`)].join("\n");
230
+ const tracesBlock = input.traces.length === 0
231
+ ? "_No traces returned for the window. Configure a Tempo / Jaeger source if traces are expected._"
232
+ : ["| trace | service | duration ms | error |", "|---|---|---|---|",
233
+ ...input.traces.slice(0, 10).map((t) => `| \`${t.traceId}\` | \`${t.rootService}\` | ${t.durationMs} | ${t.hasError ? "yes" : "no"} |`),
234
+ ...(errorTraces > 0 ? [`\n_${errorTraces} of the returned traces carried error spans._`] : [])].join("\n");
235
+ const logHighlightsBlock = (input.logHighlights ?? []).length === 0
236
+ ? "_No log highlights._"
237
+ : input.logHighlights.map((l) => `- ${l}`).join("\n");
238
+ const followUpsBlock = followUps.map((f) => `- ${f}`).join("\n");
239
+ return {
240
+ service: input.service,
241
+ window: input.window,
242
+ from: input.fromIso,
243
+ to: input.toIso,
244
+ tenant: input.tenant,
245
+ synopsis,
246
+ timeline: timelineBlock,
247
+ blastRadius: blastRadiusBlock,
248
+ signals: signalsBlock,
249
+ traces: tracesBlock,
250
+ logHighlights: logHighlightsBlock,
251
+ followUps: followUpsBlock,
252
+ };
253
+ }
254
+ /** Interpolate `{{token}}` placeholders. An unknown token is left verbatim so
255
+ * a typo is visible in the output rather than silently producing a blank. */
256
+ export function renderTemplate(template, vars) {
257
+ return template.replace(/\{\{\s*([a-zA-Z][\w]*)\s*\}\}/g, (whole, key) => Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : whole);
258
+ }
@@ -139,3 +139,30 @@ test("synthesizePostmortem: report carries the input window + iso bounds back in
139
139
  assert.equal(r.fromIso, "2026-06-06T00:00:00.000Z");
140
140
  assert.equal(r.toIso, "2026-06-06T01:00:00.000Z");
141
141
  });
142
+ test("custom template: tokens are interpolated; default path unchanged when no template", () => {
143
+ const data = input({
144
+ anomalies: [anomaly("2026-06-06T00:10:00Z", 0.7, "mad", "warn", "cpu")],
145
+ blastRadius: { nodes: [{ id: "n1", kind: "pod", name: "payment-1", root: true }], edges: [] },
146
+ logHighlights: ["payment: 5xx spike"],
147
+ });
148
+ // Default (no template) still produces the built-in report.
149
+ const def = synthesizePostmortem(data);
150
+ assert.match(def.markdown, /^# Post-mortem — payment/);
151
+ // Custom template interpolates the known tokens.
152
+ const tpl = "INCIDENT {{service}} ({{window}})\n\n{{synopsis}}\n\nTIMELINE:\n{{timeline}}\n\nFOLLOWUPS:\n{{followUps}}\n\nLOGS:\n{{logHighlights}}";
153
+ const out = synthesizePostmortem({ ...data, template: tpl }).markdown;
154
+ assert.match(out, /^INCIDENT payment \(1h\)/);
155
+ assert.ok(!out.includes("# Post-mortem"), "custom template replaces the default layout");
156
+ assert.match(out, /TIMELINE:\n\| ts \| service \| score/);
157
+ assert.match(out, /LOGS:\n- payment: 5xx spike/);
158
+ assert.ok(out.includes(def.synopsis), "synopsis token expands to the computed synopsis");
159
+ });
160
+ test("custom template: unknown token is left verbatim (visible typo, not silent blank)", () => {
161
+ const out = synthesizePostmortem({ ...input(), template: "{{service}} / {{nope}}" }).markdown;
162
+ assert.equal(out, "payment / {{nope}}");
163
+ });
164
+ test("custom template: empty sections render their placeholder text, not a crash", () => {
165
+ const out = synthesizePostmortem({ ...input(), template: "T:{{timeline}} L:{{logHighlights}}" }).markdown;
166
+ assert.match(out, /T:_No anomaly samples in this window\._/);
167
+ assert.match(out, /L:_No log highlights\._/);
168
+ });