@checkstack/ai-backend 0.1.6 → 0.3.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.
@@ -2,8 +2,11 @@ import type { PluginMetadata } from "@checkstack/common";
2
2
  import type {
3
3
  AiToolExtensionPoint,
4
4
  AiToolProjectionExtensionPoint,
5
+ SystemSignalsContributor,
6
+ SystemSignalsExtensionPoint,
5
7
  } from "./extension-points";
6
8
  import { buildProjectedTool } from "./projection";
9
+ import { toProviderToolName } from "./tool-name";
7
10
  import type { AiToolRegistry } from "./tool-registry";
8
11
 
9
12
  /**
@@ -57,7 +60,10 @@ export function createRegistryExtensionPoints({
57
60
  const tool = buildProjectedTool(input);
58
61
  registry.register(tool);
59
62
  exposedProjections.push({
60
- toolName: tool.name,
63
+ // Match the registry's canonical (provider-safe) key so the chat
64
+ // read-loop and MCP transport resolve this route by the same name the
65
+ // model is given and echoes back.
66
+ toolName: toProviderToolName(tool.name),
61
67
  pluginId: input.sourcePluginMetadata.pluginId,
62
68
  procedureKey: input.procedureKey,
63
69
  });
@@ -66,3 +72,25 @@ export function createRegistryExtensionPoints({
66
72
 
67
73
  return { toolExtensionPoint, projectionExtensionPoint, exposedProjections };
68
74
  }
75
+
76
+ /**
77
+ * Build the {@link SystemSignalsExtensionPoint} implementation that accumulates
78
+ * every registered contributor into a shared array, mirroring how {@link
79
+ * createRegistryExtensionPoints} accumulates `exposedProjections`. The returned
80
+ * `contributors` array is the SAME reference the `system.issues` tool reads at
81
+ * execute time, so contributors registered from any plugin's `init` are visible
82
+ * to the tool by the time it runs.
83
+ */
84
+ export function createSystemSignalsExtensionPoint(): {
85
+ systemSignalsExtensionPoint: SystemSignalsExtensionPoint;
86
+ /** Every contributor registered via the point (populated lazily at init). */
87
+ contributors: SystemSignalsContributor[];
88
+ } {
89
+ const contributors: SystemSignalsContributor[] = [];
90
+ const systemSignalsExtensionPoint: SystemSignalsExtensionPoint = {
91
+ contribute: (contributor) => {
92
+ contributors.push(contributor);
93
+ },
94
+ };
95
+ return { systemSignalsExtensionPoint, contributors };
96
+ }
@@ -26,24 +26,24 @@ describe("createAiToolResolver.resolveTools", () => {
26
26
  test("a principal lacking automation.manage never sees automation.propose", () => {
27
27
  const registry = createAiToolRegistry();
28
28
  registry.register(
29
- tool("automation.propose", ["automation.automation.manage"]),
29
+ tool("automation_propose", ["automation.automation.manage"]),
30
30
  );
31
- registry.register(tool("incident.list", ["incident.incident.read"]));
31
+ registry.register(tool("incident_list", ["incident.incident.read"]));
32
32
  const resolver = createAiToolResolver({ registry });
33
33
 
34
34
  const principal = userWith(["incident.incident.read"]);
35
35
  const names = resolver.resolveTools(principal).map((t) => t.name);
36
36
 
37
- expect(names).toEqual(["incident.list"]);
38
- expect(names).not.toContain("automation.propose");
37
+ expect(names).toEqual(["incident_list"]);
38
+ expect(names).not.toContain("automation_propose");
39
39
  });
40
40
 
41
41
  test("an admin (accessRules ['*']) sees all tools", () => {
42
42
  const registry = createAiToolRegistry();
43
43
  registry.register(
44
- tool("automation.propose", ["automation.automation.manage"]),
44
+ tool("automation_propose", ["automation.automation.manage"]),
45
45
  );
46
- registry.register(tool("incident.list", ["incident.incident.read"]));
46
+ registry.register(tool("incident_list", ["incident.incident.read"]));
47
47
  const resolver = createAiToolResolver({ registry });
48
48
 
49
49
  const names = resolver
@@ -51,12 +51,12 @@ describe("createAiToolResolver.resolveTools", () => {
51
51
  .map((t) => t.name)
52
52
  .sort();
53
53
 
54
- expect(names).toEqual(["automation.propose", "incident.list"]);
54
+ expect(names).toEqual(["automation_propose", "incident_list"]);
55
55
  });
56
56
 
57
57
  test("a service principal (no access rules) sees no tools", () => {
58
58
  const registry = createAiToolRegistry();
59
- registry.register(tool("incident.list", ["incident.incident.read"]));
59
+ registry.register(tool("incident_list", ["incident.incident.read"]));
60
60
  const resolver = createAiToolResolver({ registry });
61
61
 
62
62
  const service: AuthUser = { type: "service", pluginId: "automation" };
@@ -0,0 +1,162 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { access } from "@checkstack/common";
3
+ import type { AuthUser } from "@checkstack/backend-api";
4
+ import type { SystemSignal, SystemSignalsMap } from "@checkstack/catalog-common";
5
+ import {
6
+ createGatedSystemSignalsContributor,
7
+ type SystemAccessResolver,
8
+ } from "./system-signals-contributor";
9
+
10
+ // A read rule for a fictional "demo" plugin: id "thing.read", qualified
11
+ // "demo.thing.read", qualified resource type "demo.thing".
12
+ const rule = access("thing", "read", "View things", { pluginId: "demo" });
13
+
14
+ const signal = (source: string): SystemSignal => ({
15
+ source,
16
+ tone: "error",
17
+ label: "Problem",
18
+ });
19
+
20
+ const globalSignals: SystemSignalsMap = {
21
+ sysA: [signal("demo")],
22
+ sysB: [signal("demo")],
23
+ };
24
+
25
+ /** A resolver that returns a fixed accessible subset and records its args. */
26
+ function stubResolver(accessible: string[]): {
27
+ resolver: SystemAccessResolver;
28
+ calls: Array<Parameters<SystemAccessResolver["accessibleSystemIds"]>[0]>;
29
+ } {
30
+ const calls: Array<
31
+ Parameters<SystemAccessResolver["accessibleSystemIds"]>[0]
32
+ > = [];
33
+ return {
34
+ calls,
35
+ resolver: {
36
+ accessibleSystemIds: async (args) => {
37
+ calls.push(args);
38
+ return accessible.filter((id) => args.systemIds.includes(id));
39
+ },
40
+ },
41
+ };
42
+ }
43
+
44
+ const userWith = (accessRules: string[]): AuthUser => ({
45
+ type: "user",
46
+ id: "u1",
47
+ accessRules,
48
+ });
49
+
50
+ describe("createGatedSystemSignalsContributor", () => {
51
+ test("global-rule principal sees every system; resolver is not consulted", async () => {
52
+ const read = mock(async () => globalSignals);
53
+ const { resolver, calls } = stubResolver([]);
54
+ const contributor = createGatedSystemSignalsContributor({
55
+ sourceId: "demo",
56
+ accessRule: rule,
57
+ resolver,
58
+ readSignals: read,
59
+ });
60
+
61
+ const result = await contributor.read({
62
+ principal: userWith(["demo.thing.read"]),
63
+ });
64
+
65
+ expect(result.accessible).toBe(true);
66
+ expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
67
+ expect(read).toHaveBeenCalledTimes(1);
68
+ expect(calls).toHaveLength(0); // global access skips the team resolver
69
+ });
70
+
71
+ test("wildcard grant is treated as global", async () => {
72
+ const contributor = createGatedSystemSignalsContributor({
73
+ sourceId: "demo",
74
+ accessRule: rule,
75
+ resolver: stubResolver([]).resolver,
76
+ readSignals: async () => globalSignals,
77
+ });
78
+ const result = await contributor.read({ principal: userWith(["*"]) });
79
+ expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
80
+ });
81
+
82
+ test("service principals are trusted (treated as global)", async () => {
83
+ const contributor = createGatedSystemSignalsContributor({
84
+ sourceId: "demo",
85
+ accessRule: rule,
86
+ resolver: stubResolver([]).resolver,
87
+ readSignals: async () => globalSignals,
88
+ });
89
+ const principal: AuthUser = { type: "service", pluginId: "svc" };
90
+ const result = await contributor.read({ principal });
91
+ expect(result.accessible).toBe(true);
92
+ expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
93
+ });
94
+
95
+ test("a manage grant satisfies the read rule (escalation) and sees all", async () => {
96
+ const contributor = createGatedSystemSignalsContributor({
97
+ sourceId: "demo",
98
+ accessRule: rule,
99
+ resolver: stubResolver([]).resolver,
100
+ readSignals: async () => globalSignals,
101
+ });
102
+ const result = await contributor.read({
103
+ principal: userWith(["demo.thing.manage"]),
104
+ });
105
+ expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
106
+ });
107
+
108
+ test("non-global user is filtered to team-granted systems (correct resourceType)", async () => {
109
+ const read = mock(async () => globalSignals);
110
+ const { resolver, calls } = stubResolver(["sysB"]);
111
+ const contributor = createGatedSystemSignalsContributor({
112
+ sourceId: "demo",
113
+ accessRule: rule,
114
+ resolver,
115
+ readSignals: read,
116
+ });
117
+
118
+ const result = await contributor.read({ principal: userWith([]) });
119
+
120
+ expect(result.accessible).toBe(true);
121
+ expect(Object.keys(result.signals)).toEqual(["sysB"]); // sysA filtered out
122
+ expect(read).toHaveBeenCalledTimes(1);
123
+ expect(calls).toHaveLength(1);
124
+ expect(calls[0]).toMatchObject({
125
+ userId: "u1",
126
+ userType: "user",
127
+ resourceType: "demo.thing", // qualifyResourceType(pluginId, resource)
128
+ action: "read",
129
+ hasGlobalAccess: false,
130
+ });
131
+ expect(calls[0].systemIds.sort()).toEqual(["sysA", "sysB"]);
132
+ });
133
+
134
+ test("non-global user with no team grants is reported inaccessible", async () => {
135
+ const { resolver } = stubResolver([]); // grants nothing
136
+ const contributor = createGatedSystemSignalsContributor({
137
+ sourceId: "demo",
138
+ accessRule: rule,
139
+ resolver,
140
+ readSignals: async () => globalSignals,
141
+ });
142
+
143
+ const result = await contributor.read({ principal: userWith([]) });
144
+
145
+ expect(result).toEqual({ accessible: false, signals: {} });
146
+ });
147
+
148
+ test("globally-clear source: accessible with no signals, resolver not called", async () => {
149
+ const { resolver, calls } = stubResolver([]);
150
+ const contributor = createGatedSystemSignalsContributor({
151
+ sourceId: "demo",
152
+ accessRule: rule,
153
+ resolver,
154
+ readSignals: async () => ({}),
155
+ });
156
+
157
+ const result = await contributor.read({ principal: userWith([]) });
158
+
159
+ expect(result).toEqual({ accessible: true, signals: {} });
160
+ expect(calls).toHaveLength(0);
161
+ });
162
+ });
@@ -0,0 +1,129 @@
1
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
2
+ import type { AccessRule } from "@checkstack/common";
3
+ import { isAccessRuleSatisfied, qualifyResourceType } from "@checkstack/common";
4
+ import { AuthApi } from "@checkstack/auth-common";
5
+ import type { SystemSignalsMap } from "@checkstack/catalog-common";
6
+ import {
7
+ principalGrantedRuleIds,
8
+ type SystemSignalsContributor,
9
+ } from "./extension-points";
10
+
11
+ /**
12
+ * Resolves the subset of `systemIds` a non-global principal may see for a
13
+ * resource type, applying the SAME team/instance grants the RPC middleware
14
+ * enforces for list/record endpoints (via auth's `getAccessibleResourceIds`).
15
+ */
16
+ export interface SystemAccessResolver {
17
+ accessibleSystemIds(args: {
18
+ userId: string;
19
+ userType: "user" | "application";
20
+ resourceType: string;
21
+ systemIds: string[];
22
+ action: "read" | "manage";
23
+ hasGlobalAccess: boolean;
24
+ }): Promise<string[]>;
25
+ }
26
+
27
+ /**
28
+ * Build a {@link SystemAccessResolver} backed by the auth plugin's
29
+ * `getAccessibleResourceIds` S2S query - the exact primitive the RPC middleware
30
+ * uses to filter list/record endpoints by team grants. A plugin gets its
31
+ * `rpcClient` from `coreServices.rpcClient` in `init`.
32
+ */
33
+ export function createSystemAccessResolver(
34
+ rpcClient: RpcClient,
35
+ ): SystemAccessResolver {
36
+ const authClient = rpcClient.forPlugin(AuthApi);
37
+ return {
38
+ accessibleSystemIds: ({
39
+ userId,
40
+ userType,
41
+ resourceType,
42
+ systemIds,
43
+ action,
44
+ hasGlobalAccess,
45
+ }) =>
46
+ authClient.getAccessibleResourceIds({
47
+ userId,
48
+ userType,
49
+ resourceType,
50
+ resourceIds: systemIds,
51
+ action,
52
+ hasGlobalAccess,
53
+ }),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Build a {@link SystemSignalsContributor} from a GLOBAL signal reader, applying
59
+ * the per-source access gate centrally so every source enforces it identically
60
+ * (and the same way the matching bulk RPC does):
61
+ *
62
+ * - A principal holding the global rule - including trusted service principals,
63
+ * which {@link principalGrantedRuleIds} maps to the wildcard - sees every
64
+ * system the source reports.
65
+ * - A real user / application WITHOUT the global rule sees only the systems its
66
+ * TEAM grants allow, via {@link SystemAccessResolver} (the same instance/team
67
+ * filtering the bulk RPC applies), so `system.issues` neither under- nor
68
+ * over-reports relative to the per-domain UI.
69
+ * - Any other principal without the global rule (anonymous, or a service lacking
70
+ * the wildcard) sees nothing.
71
+ *
72
+ * `readSignals` MUST return problem signals for ALL systems globally; the gate
73
+ * filters them. It is not called for a principal that can see nothing, so a
74
+ * no-access principal triggers no query.
75
+ */
76
+ export function createGatedSystemSignalsContributor({
77
+ sourceId,
78
+ accessRule,
79
+ resolver,
80
+ readSignals,
81
+ }: {
82
+ sourceId: string;
83
+ accessRule: AccessRule;
84
+ resolver: SystemAccessResolver;
85
+ readSignals: () => Promise<SystemSignalsMap>;
86
+ }): SystemSignalsContributor {
87
+ const resourceType = qualifyResourceType(
88
+ accessRule.pluginId,
89
+ accessRule.resource,
90
+ );
91
+ return {
92
+ sourceId,
93
+ read: async ({ principal }: { principal: AuthUser }) => {
94
+ const hasGlobalAccess = isAccessRuleSatisfied(
95
+ principalGrantedRuleIds(principal),
96
+ accessRule,
97
+ );
98
+ if (hasGlobalAccess) {
99
+ return { accessible: true, signals: await readSignals() };
100
+ }
101
+ // Only real users / applications can carry per-team instance grants.
102
+ if (principal.type !== "user" && principal.type !== "application") {
103
+ return { accessible: false, signals: {} };
104
+ }
105
+ const signals = await readSignals();
106
+ const systemIds = Object.keys(signals);
107
+ if (systemIds.length === 0) {
108
+ // Source is globally clear: nothing to report and nothing to hide.
109
+ return { accessible: true, signals: {} };
110
+ }
111
+ const accessibleIds = new Set(
112
+ await resolver.accessibleSystemIds({
113
+ userId: principal.id,
114
+ userType: principal.type,
115
+ resourceType,
116
+ systemIds,
117
+ action: "read",
118
+ hasGlobalAccess: false,
119
+ }),
120
+ );
121
+ const filtered: SystemSignalsMap = {};
122
+ for (const [systemId, sigs] of Object.entries(signals)) {
123
+ if (accessibleIds.has(systemId)) filtered[systemId] = sigs;
124
+ }
125
+ // Accessible iff the principal may see at least one affected system.
126
+ return { accessible: accessibleIds.size > 0, signals: filtered };
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PROVIDER_TOOL_NAME_PATTERN, toProviderToolName } from "./tool-name";
3
+
4
+ describe("toProviderToolName", () => {
5
+ test("maps the '.' namespace separator to '_'", () => {
6
+ expect(toProviderToolName("incident.list")).toBe("incident_list");
7
+ expect(toProviderToolName("catalog.listSystems")).toBe(
8
+ "catalog_listSystems",
9
+ );
10
+ expect(toProviderToolName("dependency.list")).toBe("dependency_list");
11
+ });
12
+
13
+ test("maps every '.' in a multi-dot name", () => {
14
+ expect(toProviderToolName("a.b.c")).toBe("a_b_c");
15
+ });
16
+
17
+ test("leaves an already provider-safe name unchanged", () => {
18
+ expect(toProviderToolName("incident_list")).toBe("incident_list");
19
+ expect(toProviderToolName("get-status")).toBe("get-status");
20
+ expect(toProviderToolName("Tool_123")).toBe("Tool_123");
21
+ });
22
+
23
+ test("the result always matches the provider pattern", () => {
24
+ for (const name of ["incident.list", "a.b.c", "get-status", "x"]) {
25
+ expect(PROVIDER_TOOL_NAME_PATTERN.test(toProviderToolName(name))).toBe(
26
+ true,
27
+ );
28
+ }
29
+ });
30
+
31
+ test("throws on an illegal character rather than silently rewriting it", () => {
32
+ expect(() => toProviderToolName("foo$bar")).toThrow(/Invalid AI tool name/);
33
+ expect(() => toProviderToolName("with space")).toThrow(
34
+ /Invalid AI tool name/,
35
+ );
36
+ expect(() => toProviderToolName("emoji😀")).toThrow(/Invalid AI tool name/);
37
+ });
38
+
39
+ test("throws on an empty name", () => {
40
+ expect(() => toProviderToolName("")).toThrow(/Invalid AI tool name/);
41
+ });
42
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Provider tool-name constraint.
3
+ *
4
+ * LLM providers (and the MCP spec) require tool names to match
5
+ * `^[a-zA-Z0-9_-]+$`.
6
+ */
7
+ export const PROVIDER_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
8
+
9
+ /**
10
+ * Convert a canonical tool name to its provider-safe form.
11
+ *
12
+ * Tool names are qualified as `<plugin>.<tool>` - the "." is the namespace
13
+ * separator of the naming convention, which the provider rejects. It is mapped
14
+ * deterministically to "_" (so `incident.list` -> `incident_list`).
15
+ *
16
+ * Any OTHER disallowed character is an authoring mistake in the tool
17
+ * definition, not stray input, so it is NOT silently rewritten: this throws so
18
+ * the bad name surfaces at registration (startup) instead of being masked.
19
+ *
20
+ * The mapping is applied at the single registration chokepoint (the tool
21
+ * registry) and the projection-routing table, so the registry key, the name
22
+ * sent to the model / MCP client, and the name the model echoes back in a tool
23
+ * call are all identical - the round-trip (name-out === name-in) holds without
24
+ * any reverse lookup.
25
+ */
26
+ export function toProviderToolName(name: string): string {
27
+ const normalized = name.replaceAll(".", "_");
28
+ if (!PROVIDER_TOOL_NAME_PATTERN.test(normalized)) {
29
+ throw new Error(
30
+ `Invalid AI tool name "${name}": after normalizing the "." separator to ` +
31
+ `"_" it must match ${String(PROVIDER_TOOL_NAME_PATTERN)} (letters, ` +
32
+ `digits, "_" and "-" only). Rename the tool to use only those ` +
33
+ `characters, with "." reserved for the <plugin>.<tool> separator.`,
34
+ );
35
+ }
36
+ return normalized;
37
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AuthUser, RpcClient } from "@checkstack/backend-api";
2
2
  import type { AiTool } from "@checkstack/ai-common";
3
+ import { toProviderToolName } from "./tool-name";
3
4
 
4
5
  /**
5
6
  * A tool whose executors run with a Checkstack {@link AuthUser} principal and
@@ -21,7 +22,12 @@ export type RegisteredAiTool<TInput = unknown, TOutput = unknown> = AiTool<
21
22
  * registry, so no capability is implemented twice.
22
23
  *
23
24
  * Tool names are already fully qualified (`<plugin>.<tool>`) by the extension
24
- * points before they reach `register`.
25
+ * points before they reach `register`. `register` then maps the name to its
26
+ * provider-safe form (see {@link toProviderToolName}) and uses that as the
27
+ * canonical key, so every consumer (serializer, chat SDK tools, MCP
28
+ * `tools/list`, and the tool-call resolution path) sees and resolves the same
29
+ * provider-safe name. A name with an illegal character (beyond the "."
30
+ * separator) is rejected here rather than rewritten.
25
31
  */
26
32
  export interface AiToolRegistry {
27
33
  register(tool: RegisteredAiTool): void;
@@ -35,12 +41,16 @@ export function createAiToolRegistry(): AiToolRegistry {
35
41
 
36
42
  return {
37
43
  register(tool: RegisteredAiTool): void {
38
- if (tools.has(tool.name)) {
44
+ // Map to the provider-safe name (e.g. `incident.list` -> `incident_list`)
45
+ // and key the registry on it, so the name sent to the model and the name
46
+ // it echoes back both match this entry. Throws on an illegal name.
47
+ const name = toProviderToolName(tool.name);
48
+ if (tools.has(name)) {
39
49
  throw new Error(
40
- `AI tool ${tool.name} already registered — likely a duplicate registration.`,
50
+ `AI tool ${name} already registered — likely a duplicate registration.`,
41
51
  );
42
52
  }
43
- tools.set(tool.name, tool);
53
+ tools.set(name, name === tool.name ? tool : { ...tool, name });
44
54
  },
45
55
 
46
56
  getTools(): RegisteredAiTool[] {
@@ -120,7 +120,7 @@ describe("docs tools registration + resolution", () => {
120
120
  .resolveTools(userWith(["ai.chat.read"]))
121
121
  .map((t) => t.name)
122
122
  .sort();
123
- expect(names).toEqual(["ai.getDoc", "ai.searchDocs"]);
123
+ expect(names).toEqual(["ai_getDoc", "ai_searchDocs"]);
124
124
  });
125
125
 
126
126
  test("a principal without ai.chat.read sees neither docs tool", () => {