@thotischner/observability-mcp 3.3.2 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/credentials.d.ts +10 -0
- package/dist/auth/credentials.js +7 -0
- package/dist/auth/credentials.test.js +16 -1
- package/dist/auth/password-policy.test.js +14 -5
- package/dist/context.d.ts +7 -0
- package/dist/context.js +1 -0
- package/dist/context.test.js +6 -0
- package/dist/enrich/ip-dataset.d.ts +14 -2
- package/dist/enrich/ip-dataset.js +115 -14
- package/dist/enrich/ip-dataset.test.js +81 -5
- package/dist/index.js +4 -2
- package/dist/tools/enrich-ips.js +8 -4
- package/dist/tools/enrich-ips.test.js +11 -0
- package/dist/tools/generate-postmortem.js +23 -4
- package/dist/tools/generate-postmortem.test.d.ts +1 -0
- package/dist/tools/generate-postmortem.test.js +22 -0
- package/dist/tools/handlers.test.js +30 -0
- package/dist/tools/list-services.js +28 -1
- package/dist/tools/list-sources.js +7 -1
- package/dist/tools/query-logs.js +1 -1
- package/dist/tools/query-metrics.js +1 -1
- package/dist/tools/topology.js +19 -0
- package/dist/tools/topology.test.js +10 -0
- package/package.json +1 -1
|
@@ -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
|
package/dist/auth/credentials.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(),
|
package/dist/context.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
58
|
-
assert.equal(ds.skipped,
|
|
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
|
}),
|
package/dist/tools/enrich-ips.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ipv4ToInt } from "../enrich/ip-dataset.js";
|
|
1
|
+
import { ipv4ToInt, ipv6ToBigInt } from "../enrich/ip-dataset.js";
|
|
2
2
|
import { defaultContext } from "../context.js";
|
|
3
3
|
import { errorResponse } from "./validation.js";
|
|
4
4
|
// enrich_ips (issue #415 Gap B): resolve a batch of IPs to geo / ASN / org /
|
|
@@ -7,9 +7,13 @@ import { errorResponse } from "./validation.js";
|
|
|
7
7
|
// when no dataset is configured.
|
|
8
8
|
export const enrichIpsDefinition = {
|
|
9
9
|
name: "enrich_ips",
|
|
10
|
-
description: "Resolve a batch of IPv4 addresses to geo (country/city), ASN/org, and a hosting/proxy flag from a local offline dataset. Use this to answer 'where are these visitors from / which are bots or datacenter IPs' without an out-of-band geo API call. Requires the operator to have configured an offline dataset (OMCP_IP_ENRICH_FILE); returns a clear notice otherwise.",
|
|
10
|
+
description: "Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag from a local offline dataset. Use this to answer 'where are these visitors from / which are bots or datacenter IPs' without an out-of-band geo API call. Requires the operator to have configured an offline dataset (OMCP_IP_ENRICH_FILE); returns a clear notice otherwise.",
|
|
11
11
|
};
|
|
12
12
|
const MAX_IPS = 1000;
|
|
13
|
+
/** A string is a valid IP if it parses as either IPv4 or IPv6. */
|
|
14
|
+
function isValidIp(ip) {
|
|
15
|
+
return ipv4ToInt(ip) !== null || ipv6ToBigInt(ip) !== null;
|
|
16
|
+
}
|
|
13
17
|
export function enrichIpsHandler(dataset, args,
|
|
14
18
|
// The RequestContext seam — enrich_ips doesn't scope by tenant today (the
|
|
15
19
|
// dataset is a single process-wide table), but every tool handler threads
|
|
@@ -22,7 +26,7 @@ _ctx = defaultContext()) {
|
|
|
22
26
|
}
|
|
23
27
|
const ips = args.ips;
|
|
24
28
|
if (!Array.isArray(ips) || ips.length === 0) {
|
|
25
|
-
return errorResponse("`ips` must be a non-empty array of IPv4 address strings.");
|
|
29
|
+
return errorResponse("`ips` must be a non-empty array of IPv4 or IPv6 address strings.");
|
|
26
30
|
}
|
|
27
31
|
if (ips.length > MAX_IPS) {
|
|
28
32
|
return errorResponse(`Too many IPs (${ips.length}); max ${MAX_IPS} per call.`);
|
|
@@ -31,7 +35,7 @@ _ctx = defaultContext()) {
|
|
|
31
35
|
let invalid = 0;
|
|
32
36
|
let matched = 0;
|
|
33
37
|
for (const ip of ips) {
|
|
34
|
-
if (typeof ip !== "string" ||
|
|
38
|
+
if (typeof ip !== "string" || !isValidIp(ip)) {
|
|
35
39
|
invalid++;
|
|
36
40
|
results.push({ ip: String(ip), found: false });
|
|
37
41
|
continue;
|
|
@@ -35,4 +35,15 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
|
|
|
35
35
|
assert.deepEqual(out.summary, { total: 3, matched: 1, unmatched: 1, invalid: 1 });
|
|
36
36
|
assert.equal(out.datasetSize, 2);
|
|
37
37
|
});
|
|
38
|
+
it("accepts IPv6 inputs and enriches them (not counted invalid)", () => {
|
|
39
|
+
const ds6 = IpEnrichmentDataset.fromCsv(["2001:db8::/32,US,,AS14618,Example Cloud,true", "1.2.3.0/24,DE,Berlin,AS3320,Example ISP,false"].join("\n"));
|
|
40
|
+
const out = parse(enrichIpsHandler(ds6, { ips: ["2001:db8::1", "2606:4700::1", "1.2.3.9"] }));
|
|
41
|
+
const v6hit = out.results.find((r) => r.ip === "2001:db8::1");
|
|
42
|
+
assert.equal(v6hit.found, true);
|
|
43
|
+
assert.equal(v6hit.org, "Example Cloud");
|
|
44
|
+
const v6miss = out.results.find((r) => r.ip === "2606:4700::1");
|
|
45
|
+
assert.equal(v6miss.found, false);
|
|
46
|
+
// A valid-but-unmatched IPv6 is "unmatched", NOT "invalid".
|
|
47
|
+
assert.deepEqual(out.summary, { total: 3, matched: 2, unmatched: 1, invalid: 0 });
|
|
48
|
+
});
|
|
38
49
|
});
|
|
@@ -60,16 +60,35 @@ export async function generatePostmortemHandler(registry, args, ctx = defaultCon
|
|
|
60
60
|
traces,
|
|
61
61
|
logHighlights,
|
|
62
62
|
});
|
|
63
|
+
// Which primitives actually carried data. A post-mortem synthesised from
|
|
64
|
+
// ZERO signal still renders a full, authoritative-looking document — an
|
|
65
|
+
// on-call could paste it into a ticket as if it were a real finding. Make
|
|
66
|
+
// the coverage explicit so an empty report is self-labelling (the
|
|
67
|
+
// "absent ≠ zero" class, cf. #453/#462).
|
|
68
|
+
const coverage = {
|
|
69
|
+
anomalies: anomalies.length > 0,
|
|
70
|
+
traces: traces.length > 0,
|
|
71
|
+
topology: blastRadius.nodes.length > 0,
|
|
72
|
+
logs: logHighlights.length > 0,
|
|
73
|
+
};
|
|
74
|
+
const anySignal = Object.values(coverage).some(Boolean);
|
|
75
|
+
const reportWithCoverage = { ...report, coverage, builtFromSignal: anySignal };
|
|
63
76
|
if ((args.format || "markdown").toLowerCase() === "json") {
|
|
64
77
|
return {
|
|
65
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
78
|
+
content: [{ type: "text", text: JSON.stringify(reportWithCoverage) }],
|
|
66
79
|
isError: false,
|
|
67
80
|
};
|
|
68
81
|
}
|
|
69
|
-
// Default: return the markdown body.
|
|
70
|
-
//
|
|
82
|
+
// Default: return the markdown body. When there was no signal at all, lead
|
|
83
|
+
// with a banner so the document isn't mistaken for a real finding.
|
|
84
|
+
const banner = anySignal
|
|
85
|
+
? ""
|
|
86
|
+
: "> ⚠️ **No signal in this window.** This report was built from zero anomalies, traces, " +
|
|
87
|
+
"topology, and log highlights. Either the window was genuinely clean, or the relevant " +
|
|
88
|
+
"backends aren't configured/writing (anomaly-history sink, traces connector, topology " +
|
|
89
|
+
"connector). Verify coverage before relying on this.\n\n";
|
|
71
90
|
return {
|
|
72
|
-
content: [{ type: "text", text: report.markdown }],
|
|
91
|
+
content: [{ type: "text", text: banner + report.markdown }],
|
|
73
92
|
isError: false,
|
|
74
93
|
};
|
|
75
94
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ConnectorRegistry } from "../connectors/registry.js";
|
|
4
|
+
import { generatePostmortemHandler } from "./generate-postmortem.js";
|
|
5
|
+
// R5 — a post-mortem built from ZERO signal (no anomaly/traces/topology/log
|
|
6
|
+
// backend) must label itself, not render as an authoritative finding.
|
|
7
|
+
describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
|
|
8
|
+
it("markdown leads with a 'no signal' banner when nothing was found", async () => {
|
|
9
|
+
const reg = new ConnectorRegistry(); // no backends → every primitive empty
|
|
10
|
+
const out = await generatePostmortemHandler(reg, { service: "ghost-service", duration: "1h" });
|
|
11
|
+
const md = out.content[0].text;
|
|
12
|
+
assert.match(md, /No signal in this window/i);
|
|
13
|
+
assert.match(md, /backends aren't configured/i);
|
|
14
|
+
});
|
|
15
|
+
it("json form carries explicit coverage flags + builtFromSignal=false", async () => {
|
|
16
|
+
const reg = new ConnectorRegistry();
|
|
17
|
+
const out = await generatePostmortemHandler(reg, { service: "ghost-service", duration: "1h", format: "json" });
|
|
18
|
+
const report = JSON.parse(out.content[0].text);
|
|
19
|
+
assert.equal(report.builtFromSignal, false);
|
|
20
|
+
assert.deepEqual(report.coverage, { anomalies: false, traces: false, topology: false, logs: false });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -152,6 +152,36 @@ describe("listServicesHandler", () => {
|
|
|
152
152
|
const data = JSON.parse(result.content[0].text);
|
|
153
153
|
assert.equal(data.total, 0);
|
|
154
154
|
});
|
|
155
|
+
it("no backends configured → note distinguishes 'none configured' from 'zero services' (R5)", async () => {
|
|
156
|
+
const data = JSON.parse((await listServicesHandler(new ConnectorRegistry(), {})).content[0].text);
|
|
157
|
+
assert.equal(data.total, 0);
|
|
158
|
+
assert.match(data.note, /No observability backends are configured/i);
|
|
159
|
+
});
|
|
160
|
+
it("all sources fail discovery → note + not a silent 'zero services' (R5)", async () => {
|
|
161
|
+
const reg = createRegistryWithMocks([
|
|
162
|
+
createMockConnector({ name: "prom1", type: "prometheus", signalType: "metrics", listServices: async () => { throw new Error("down"); } }),
|
|
163
|
+
]);
|
|
164
|
+
const data = JSON.parse((await listServicesHandler(reg, {})).content[0].text);
|
|
165
|
+
assert.equal(data.total, 0);
|
|
166
|
+
assert.match(data.note, /failed on all 1 configured source/i);
|
|
167
|
+
});
|
|
168
|
+
it("partial source failure → result is flagged partial with the failed source (R5)", async () => {
|
|
169
|
+
const reg = createRegistryWithMocks([
|
|
170
|
+
createMockConnector({ name: "prom1", type: "prometheus", signalType: "metrics", listServices: async () => { throw new Error("down"); } }),
|
|
171
|
+
createMockConnector({ name: "loki1", type: "loki", signalType: "logs", listServices: async () => [{ name: "order-service", source: "loki1", signalType: "logs" }] }),
|
|
172
|
+
]);
|
|
173
|
+
const data = JSON.parse((await listServicesHandler(reg, {})).content[0].text);
|
|
174
|
+
assert.equal(data.total, 1);
|
|
175
|
+
assert.equal(data.partial, true);
|
|
176
|
+
assert.deepEqual(data.failedSources, ["prom1"]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe("listSourcesHandler — no-data honesty (R5)", () => {
|
|
180
|
+
it("no backends configured → explanatory note, not a bare empty list", async () => {
|
|
181
|
+
const data = JSON.parse((await listSourcesHandler(new ConnectorRegistry())).content[0].text);
|
|
182
|
+
assert.deepEqual(data.sources, []);
|
|
183
|
+
assert.match(data.note, /No observability backends are configured/i);
|
|
184
|
+
});
|
|
155
185
|
});
|
|
156
186
|
describe("detectAnomaliesHandler — fleet coverage (issue #453B)", () => {
|
|
157
187
|
it("fleet scan includes log-only services and reports per-service coverage", async () => {
|
|
@@ -16,6 +16,10 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
|
|
|
16
16
|
// Tenant-scoped: only consult sources the caller can see.
|
|
17
17
|
const connectors = registry.getByTenant(ctx.tenant);
|
|
18
18
|
const allServices = [];
|
|
19
|
+
// Track per-connector discovery failures so an empty result isn't silently
|
|
20
|
+
// partial — a down source must not make half the fleet vanish without a
|
|
21
|
+
// signal (the "absent ≠ zero" class, cf. #453).
|
|
22
|
+
const failedSources = [];
|
|
19
23
|
for (const connector of connectors) {
|
|
20
24
|
try {
|
|
21
25
|
const services = await connector.listServices();
|
|
@@ -23,6 +27,7 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
|
|
|
23
27
|
}
|
|
24
28
|
catch (err) {
|
|
25
29
|
console.error(`Failed to list services from ${connector.name}:`, err);
|
|
30
|
+
failedSources.push(connector.name);
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
// Deduplicate by name, merge signal types. Carry per-service `labels`
|
|
@@ -54,11 +59,33 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
|
|
|
54
59
|
const f = args.filter.toLowerCase();
|
|
55
60
|
services = services.filter((s) => s.name.toLowerCase().includes(f));
|
|
56
61
|
}
|
|
62
|
+
// An empty result is ambiguous — say *why* so an agent doesn't read it as
|
|
63
|
+
// "this environment has no services". Distinguish: no backends configured,
|
|
64
|
+
// discovery failed on some/all sources, or genuinely none discovered.
|
|
65
|
+
let note;
|
|
66
|
+
if (connectors.length === 0) {
|
|
67
|
+
note = "No observability backends are configured for this tenant — add one via the Sources tab or config/sources.yaml. This is not 'zero services'.";
|
|
68
|
+
}
|
|
69
|
+
else if (failedSources.length === connectors.length) {
|
|
70
|
+
note = `Service discovery failed on all ${connectors.length} configured source(s) (${failedSources.join(", ")}) — the empty result is an error, not 'zero services'. Check source health via list_sources.`;
|
|
71
|
+
}
|
|
72
|
+
else if (services.length === 0 && !args.filter) {
|
|
73
|
+
note = "No services discovered — the configured backend(s) returned none in this tenant.";
|
|
74
|
+
}
|
|
57
75
|
return {
|
|
58
76
|
content: [
|
|
59
77
|
{
|
|
60
78
|
type: "text",
|
|
61
|
-
text: JSON.stringify({
|
|
79
|
+
text: JSON.stringify({
|
|
80
|
+
services,
|
|
81
|
+
total: services.length,
|
|
82
|
+
// Only present when some (but not all) sources failed — a partial
|
|
83
|
+
// result the caller should know is incomplete.
|
|
84
|
+
...(failedSources.length > 0 && failedSources.length < connectors.length
|
|
85
|
+
? { partial: true, failedSources }
|
|
86
|
+
: {}),
|
|
87
|
+
...(note ? { note } : {}),
|
|
88
|
+
}, null, 2),
|
|
62
89
|
},
|
|
63
90
|
],
|
|
64
91
|
};
|
|
@@ -21,11 +21,17 @@ export async function listSourcesHandler(registry, ctx = defaultContext()) {
|
|
|
21
21
|
status: healthResults[c.name]?.status || "unknown",
|
|
22
22
|
latencyMs: healthResults[c.name]?.latencyMs,
|
|
23
23
|
}));
|
|
24
|
+
// An empty list is ambiguous on its own — name the cause so an agent
|
|
25
|
+
// doesn't read it as a transient/permission blip (the "absent ≠ zero"
|
|
26
|
+
// class). When nothing is configured, say so explicitly.
|
|
27
|
+
const note = sources.length === 0
|
|
28
|
+
? "No observability backends are configured for this tenant. Add one via the Sources tab or config/sources.yaml — this is 'none configured', not a query error."
|
|
29
|
+
: undefined;
|
|
24
30
|
return {
|
|
25
31
|
content: [
|
|
26
32
|
{
|
|
27
33
|
type: "text",
|
|
28
|
-
text: JSON.stringify({ sources }, null, 2),
|
|
34
|
+
text: JSON.stringify({ sources, ...(note ? { note } : {}) }, null, 2),
|
|
29
35
|
},
|
|
30
36
|
],
|
|
31
37
|
};
|
package/dist/tools/query-logs.js
CHANGED
|
@@ -59,7 +59,7 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext(), o
|
|
|
59
59
|
return errorResponse(rawErr);
|
|
60
60
|
const isRaw = !!args.raw_query;
|
|
61
61
|
if (isRaw && !opts.allowRawQuery) {
|
|
62
|
-
return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on) to run verbatim LogQL — it bypasses the curated log surface, so it is off by default.");
|
|
62
|
+
return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on globally, or per-credential via OMCP_KEY_RAW_QUERY) to run verbatim LogQL — it bypasses the curated log surface, so it is off by default.");
|
|
63
63
|
}
|
|
64
64
|
if (isRaw && args.aggregate) {
|
|
65
65
|
return errorResponse("raw_query and aggregate are mutually exclusive — a raw LogQL query expresses its own aggregation.");
|
|
@@ -50,7 +50,7 @@ export async function queryMetricsHandler(registry, args, ctx = defaultContext()
|
|
|
50
50
|
return errorResponse(rawErr);
|
|
51
51
|
const isRaw = !!args.raw_query;
|
|
52
52
|
if (isRaw && !opts.allowRawQuery) {
|
|
53
|
-
return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on) to run verbatim PromQL — it bypasses the curated metric surface, so it is off by default.");
|
|
53
|
+
return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on globally, or per-credential via OMCP_KEY_RAW_QUERY) to run verbatim PromQL — it bypasses the curated metric surface, so it is off by default.");
|
|
54
54
|
}
|
|
55
55
|
if (!isRaw) {
|
|
56
56
|
if (!args.service)
|
package/dist/tools/topology.js
CHANGED
|
@@ -171,6 +171,25 @@ export const getBlastRadiusDefinition = {
|
|
|
171
171
|
};
|
|
172
172
|
export async function getBlastRadiusHandler(registry, args, ctx = defaultContext()) {
|
|
173
173
|
const agg = await aggregateTopology(registry, ctx.tenant);
|
|
174
|
+
// No topology-capable connector → the resource can't be found because there
|
|
175
|
+
// IS no graph, not because the name is wrong. Say so explicitly instead of a
|
|
176
|
+
// generic "not found" that misattributes the cause (the "absent ≠ zero"
|
|
177
|
+
// class, consistent with get_topology's empty-graph note).
|
|
178
|
+
if (agg.sources.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
isError: true,
|
|
181
|
+
content: [{
|
|
182
|
+
type: "text",
|
|
183
|
+
text: JSON.stringify({
|
|
184
|
+
resource: args.resource,
|
|
185
|
+
hosts: [],
|
|
186
|
+
note: "No topology-capable connector is configured, so there is no blast radius to compute. " +
|
|
187
|
+
"Add a topology source (the built-in `kubernetes` connector, or aws/gcp/istio/linkerd/consul) " +
|
|
188
|
+
"— a metrics/logs-only deployment has no topology graph. This is not 'resource not found'.",
|
|
189
|
+
}, null, 2),
|
|
190
|
+
}],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
174
193
|
const found = resolveResource(args.resource, agg.resources);
|
|
175
194
|
if ("error" in found) {
|
|
176
195
|
return {
|
|
@@ -212,6 +212,16 @@ describe("get_blast_radius tool", () => {
|
|
|
212
212
|
const out = parseTool(result);
|
|
213
213
|
assert.match(out.error, /No resource found/);
|
|
214
214
|
});
|
|
215
|
+
it("no topology connector → explicit note, not a misleading 'resource not found' (R5)", async () => {
|
|
216
|
+
// An empty registry has no topology source. The cause is "no graph", not
|
|
217
|
+
// "wrong name" — the response must say so rather than blame the resource.
|
|
218
|
+
const reg = new ConnectorRegistry(new PluginLoader());
|
|
219
|
+
const result = await getBlastRadiusHandler(reg, { resource: "anything" });
|
|
220
|
+
assert.equal(result.isError, true);
|
|
221
|
+
const out = parseTool(result);
|
|
222
|
+
assert.match(out.note, /No topology-capable connector is configured/i);
|
|
223
|
+
assert.ok(!out.error || !/No resource found/.test(out.error), "must not misattribute to a not-found name");
|
|
224
|
+
});
|
|
215
225
|
it("uses ownership root, not direct owner, when grouping co-tenants", async () => {
|
|
216
226
|
// api-aaa is OWNED_BY a ReplicaSet which is OWNED_BY a Deployment.
|
|
217
227
|
// The blast-radius should bucket api-aaa under the Deployment, not the RS.
|
package/package.json
CHANGED