@checkstack/ai-backend 0.1.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/CHANGELOG.md +97 -0
- package/drizzle/0000_productive_jackpot.sql +26 -0
- package/drizzle/0001_puzzling_purple_man.sql +26 -0
- package/drizzle/0002_sparkling_paper_doll.sql +15 -0
- package/drizzle/0003_married_senator_kelly.sql +1 -0
- package/drizzle/0004_crazy_miek.sql +2 -0
- package/drizzle/0005_tearful_randall_flagg.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +232 -0
- package/drizzle/meta/0001_snapshot.json +434 -0
- package/drizzle/meta/0002_snapshot.json +551 -0
- package/drizzle/meta/0003_snapshot.json +557 -0
- package/drizzle/meta/0004_snapshot.json +573 -0
- package/drizzle/meta/0005_snapshot.json +574 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +42 -0
- package/src/agent-runner.test.ts +262 -0
- package/src/agent-runner.ts +262 -0
- package/src/chat/agent-loop.test.ts +119 -0
- package/src/chat/agent-loop.ts +73 -0
- package/src/chat/auto-apply.test.ts +237 -0
- package/src/chat/chat-handler.ts +111 -0
- package/src/chat/chat-service.streamturn.test.ts +417 -0
- package/src/chat/chat-service.test.ts +250 -0
- package/src/chat/chat-service.ts +923 -0
- package/src/chat/classifier-service.ts +64 -0
- package/src/chat/classifier.logic.test.ts +92 -0
- package/src/chat/classifier.logic.ts +71 -0
- package/src/chat/conversation-store.it.test.ts +203 -0
- package/src/chat/conversation-store.test.ts +248 -0
- package/src/chat/conversation-store.ts +237 -0
- package/src/chat/decision.logic.test.ts +45 -0
- package/src/chat/decision.logic.ts +54 -0
- package/src/chat/llm-provider.test.ts +63 -0
- package/src/chat/llm-provider.ts +67 -0
- package/src/chat/model-error.logic.test.ts +60 -0
- package/src/chat/model-error.logic.ts +65 -0
- package/src/chat/normalize-messages.logic.test.ts +101 -0
- package/src/chat/normalize-messages.logic.ts +65 -0
- package/src/chat/permission-mode.logic.test.ts +70 -0
- package/src/chat/permission-mode.logic.ts +45 -0
- package/src/chat/read-invoker.ts +72 -0
- package/src/chat/replay.test.ts +174 -0
- package/src/chat/scrub-content.test.ts +183 -0
- package/src/chat/scrub-content.ts +154 -0
- package/src/chat/sdk-tools.test.ts +168 -0
- package/src/chat/sdk-tools.ts +181 -0
- package/src/chat/title-service.test.ts +146 -0
- package/src/chat/title-service.ts +111 -0
- package/src/chat/title.logic.test.ts +98 -0
- package/src/chat/title.logic.ts +102 -0
- package/src/extension-points.ts +41 -0
- package/src/generated/docs-index.ts +3020 -0
- package/src/hardening/handler-authz.test.ts +282 -0
- package/src/hardening/no-secret-leak.test.ts +303 -0
- package/src/hooks.ts +33 -0
- package/src/index.ts +542 -0
- package/src/mcp/connection-registry.test.ts +25 -0
- package/src/mcp/connection-registry.ts +54 -0
- package/src/mcp/mcp-conformance.it.test.ts +128 -0
- package/src/mcp/server.test.ts +285 -0
- package/src/mcp/server.ts +300 -0
- package/src/mcp/tool-invoker.ts +65 -0
- package/src/openai-provider.test.ts +64 -0
- package/src/openai-provider.ts +146 -0
- package/src/projection.test.ts +97 -0
- package/src/projection.ts +132 -0
- package/src/propose-apply/args-hash.test.ts +26 -0
- package/src/propose-apply/args-hash.ts +30 -0
- package/src/propose-apply/service.test.ts +423 -0
- package/src/propose-apply/service.ts +419 -0
- package/src/propose-apply/store.test.ts +136 -0
- package/src/propose-apply/store.ts +224 -0
- package/src/propose-apply/token.test.ts +52 -0
- package/src/propose-apply/token.ts +71 -0
- package/src/rate-limit/spend-ledger.it.test.ts +224 -0
- package/src/rate-limit/spend-ledger.test.ts +176 -0
- package/src/rate-limit/spend-ledger.ts +162 -0
- package/src/rate-limit/tool-budget.it.test.ts +173 -0
- package/src/rate-limit/tool-budget.test.ts +58 -0
- package/src/rate-limit/tool-budget.ts +107 -0
- package/src/registry-wiring.test.ts +131 -0
- package/src/registry-wiring.ts +68 -0
- package/src/resolver.test.ts +156 -0
- package/src/resolver.ts +78 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +345 -0
- package/src/schema.ts +284 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +42 -0
- package/src/tool-registry.ts +58 -0
- package/src/tools/composite-tools.ts +24 -0
- package/src/tools/docs-tools.test.ts +150 -0
- package/src/tools/docs-tools.ts +115 -0
- package/src/tools/probe-url.test.ts +51 -0
- package/src/tools/probe-url.ts +146 -0
- package/src/tools/rank-docs.test.ts +153 -0
- package/src/tools/rank-docs.ts +209 -0
- package/src/tools/script-context-extract.test.ts +93 -0
- package/src/tools/script-context-extract.ts +283 -0
- package/src/tools/ssrf-guard.test.ts +69 -0
- package/src/tools/ssrf-guard.ts +108 -0
- package/src/tools/tool-set.e2e.test.ts +64 -0
- package/src/user-rpc-client.test.ts +45 -0
- package/src/user-rpc-client.ts +60 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { access, definePluginMetadata, proc } from "@checkstack/common";
|
|
4
|
+
import { AiToolEffectSchema } from "@checkstack/ai-common";
|
|
5
|
+
import type { AnyContractProcedure } from "@orpc/contract";
|
|
6
|
+
import { createAiToolRegistry } from "./tool-registry";
|
|
7
|
+
import { createRegistryExtensionPoints } from "./registry-wiring";
|
|
8
|
+
import type { RegisteredAiTool } from "./tool-registry";
|
|
9
|
+
|
|
10
|
+
const sourcePluginMetadata = definePluginMetadata({ pluginId: "incident" });
|
|
11
|
+
const incidentRead = access("incident", "read", "View incidents");
|
|
12
|
+
const listIncidents = proc({
|
|
13
|
+
operationType: "query",
|
|
14
|
+
userType: "authenticated",
|
|
15
|
+
access: [incidentRead],
|
|
16
|
+
}).input(z.object({ status: z.string().optional() })) as AnyContractProcedure;
|
|
17
|
+
|
|
18
|
+
function handAuthoredTool(): RegisteredAiTool {
|
|
19
|
+
return {
|
|
20
|
+
// Unqualified name — the extension point must qualify it with the plugin id.
|
|
21
|
+
name: "propose",
|
|
22
|
+
description: "Propose an automation from natural language.",
|
|
23
|
+
effect: "mutate",
|
|
24
|
+
input: z.object({ prompt: z.string() }),
|
|
25
|
+
requiredAccessRules: ["automation.automation.manage"],
|
|
26
|
+
execute: () => Promise.resolve({}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("createRegistryExtensionPoints (end-to-end registration)", () => {
|
|
31
|
+
test("registerTool qualifies an unqualified name with the plugin id", () => {
|
|
32
|
+
const registry = createAiToolRegistry();
|
|
33
|
+
const { toolExtensionPoint } = createRegistryExtensionPoints({ registry });
|
|
34
|
+
|
|
35
|
+
toolExtensionPoint.registerTool(
|
|
36
|
+
handAuthoredTool(),
|
|
37
|
+
definePluginMetadata({ pluginId: "automation" }),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(registry.hasTool("automation.propose")).toBe(true);
|
|
41
|
+
expect(registry.getTool("automation.propose")?.effect).toBe("mutate");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("registerTool leaves an already-qualified name unchanged", () => {
|
|
45
|
+
const registry = createAiToolRegistry();
|
|
46
|
+
const { toolExtensionPoint } = createRegistryExtensionPoints({ registry });
|
|
47
|
+
|
|
48
|
+
toolExtensionPoint.registerTool(
|
|
49
|
+
{ ...handAuthoredTool(), name: "automation.propose" },
|
|
50
|
+
definePluginMetadata({ pluginId: "different" }),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(registry.hasTool("automation.propose")).toBe(true);
|
|
54
|
+
expect(registry.hasTool("different.automation.propose")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("expose builds and registers a projected tool from a contract procedure", () => {
|
|
58
|
+
const registry = createAiToolRegistry();
|
|
59
|
+
const { projectionExtensionPoint } = createRegistryExtensionPoints({
|
|
60
|
+
registry,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
projectionExtensionPoint.expose({
|
|
64
|
+
procedure: listIncidents,
|
|
65
|
+
sourcePluginMetadata,
|
|
66
|
+
procedureKey: "listIncidents",
|
|
67
|
+
name: "incident.list",
|
|
68
|
+
description: "List incidents.",
|
|
69
|
+
effect: "read",
|
|
70
|
+
execute: () => Promise.resolve({}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const tool = registry.getTool("incident.list");
|
|
74
|
+
expect(tool).toBeDefined();
|
|
75
|
+
// Access rules read verbatim from the source procedure, qualified.
|
|
76
|
+
expect(tool?.requiredAccessRules).toEqual(["incident.incident.read"]);
|
|
77
|
+
expect(tool?.effect).toBe("read");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("both paths populate the SAME registry (one spine, two paths)", () => {
|
|
81
|
+
const registry = createAiToolRegistry();
|
|
82
|
+
const { toolExtensionPoint, projectionExtensionPoint } =
|
|
83
|
+
createRegistryExtensionPoints({ registry });
|
|
84
|
+
|
|
85
|
+
toolExtensionPoint.registerTool(
|
|
86
|
+
handAuthoredTool(),
|
|
87
|
+
definePluginMetadata({ pluginId: "automation" }),
|
|
88
|
+
);
|
|
89
|
+
projectionExtensionPoint.expose({
|
|
90
|
+
procedure: listIncidents,
|
|
91
|
+
sourcePluginMetadata,
|
|
92
|
+
procedureKey: "listIncidents",
|
|
93
|
+
name: "incident.list",
|
|
94
|
+
description: "List incidents.",
|
|
95
|
+
effect: "read",
|
|
96
|
+
execute: () => Promise.resolve({}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(registry.getTools().map((t) => t.name).sort()).toEqual([
|
|
100
|
+
"automation.propose",
|
|
101
|
+
"incident.list",
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// §4.2 belt-and-suspenders: every registered tool carries a VALID effect.
|
|
106
|
+
// This is already guaranteed by the `AiToolEffect` type at compile time, but
|
|
107
|
+
// the runtime assertion documents the invariant the permission-mode gating
|
|
108
|
+
// keys on - an effect outside `read | mutate | destructive` would slip the
|
|
109
|
+
// 3-tier disposition logic.
|
|
110
|
+
test("every registered tool has a valid effect", () => {
|
|
111
|
+
const registry = createAiToolRegistry();
|
|
112
|
+
const { toolExtensionPoint, projectionExtensionPoint } =
|
|
113
|
+
createRegistryExtensionPoints({ registry });
|
|
114
|
+
toolExtensionPoint.registerTool(
|
|
115
|
+
handAuthoredTool(),
|
|
116
|
+
definePluginMetadata({ pluginId: "automation" }),
|
|
117
|
+
);
|
|
118
|
+
projectionExtensionPoint.expose({
|
|
119
|
+
procedure: listIncidents,
|
|
120
|
+
sourcePluginMetadata,
|
|
121
|
+
procedureKey: "listIncidents",
|
|
122
|
+
name: "incident.list",
|
|
123
|
+
description: "List incidents.",
|
|
124
|
+
effect: "read",
|
|
125
|
+
execute: () => Promise.resolve({}),
|
|
126
|
+
});
|
|
127
|
+
for (const tool of registry.getTools()) {
|
|
128
|
+
expect(AiToolEffectSchema.safeParse(tool.effect).success).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
2
|
+
import type {
|
|
3
|
+
AiToolExtensionPoint,
|
|
4
|
+
AiToolProjectionExtensionPoint,
|
|
5
|
+
} from "./extension-points";
|
|
6
|
+
import { buildProjectedTool } from "./projection";
|
|
7
|
+
import type { AiToolRegistry } from "./tool-registry";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the two extension-point implementations that feed a tool {@link
|
|
11
|
+
* AiToolRegistry}. Factored out of the plugin `register()` so the exact
|
|
12
|
+
* production wiring (name qualification for hand-authored tools, `expose`
|
|
13
|
+
* building a projected tool from a contract procedure) is unit-testable
|
|
14
|
+
* end-to-end without standing up a full plugin environment.
|
|
15
|
+
*
|
|
16
|
+
* Both paths are the ONLY way a tool reaches the registry:
|
|
17
|
+
* - `registerTool` qualifies a hand-authored composite tool's name with the
|
|
18
|
+
* registering plugin id (unless already qualified).
|
|
19
|
+
* - `expose` builds a projected tool from an oRPC contract procedure via
|
|
20
|
+
* {@link buildProjectedTool} and registers it.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Routing metadata for a projected read tool, accumulated as plugins `expose`
|
|
24
|
+
* their own read procedures. The MCP transport and the chat read-loop re-enter
|
|
25
|
+
* the live router using `{ pluginId, procedureKey }`, so ai-backend collects this
|
|
26
|
+
* AFTER all plugins have registered (in `afterPluginsReady`) - it does not need
|
|
27
|
+
* to know which plugins exist or import their `*-common`.
|
|
28
|
+
*/
|
|
29
|
+
export interface ExposedProjectionRoute {
|
|
30
|
+
toolName: string;
|
|
31
|
+
pluginId: string;
|
|
32
|
+
procedureKey: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRegistryExtensionPoints({
|
|
36
|
+
registry,
|
|
37
|
+
}: {
|
|
38
|
+
registry: AiToolRegistry;
|
|
39
|
+
}): {
|
|
40
|
+
toolExtensionPoint: AiToolExtensionPoint;
|
|
41
|
+
projectionExtensionPoint: AiToolProjectionExtensionPoint;
|
|
42
|
+
/** Routing for every projection exposed via the point (populated lazily). */
|
|
43
|
+
exposedProjections: ExposedProjectionRoute[];
|
|
44
|
+
} {
|
|
45
|
+
const toolExtensionPoint: AiToolExtensionPoint = {
|
|
46
|
+
registerTool: (tool, metadata: PluginMetadata) => {
|
|
47
|
+
const qualifiedName = tool.name.includes(".")
|
|
48
|
+
? tool.name
|
|
49
|
+
: `${metadata.pluginId}.${tool.name}`;
|
|
50
|
+
registry.register({ ...tool, name: qualifiedName });
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const exposedProjections: ExposedProjectionRoute[] = [];
|
|
55
|
+
const projectionExtensionPoint: AiToolProjectionExtensionPoint = {
|
|
56
|
+
expose: (input) => {
|
|
57
|
+
const tool = buildProjectedTool(input);
|
|
58
|
+
registry.register(tool);
|
|
59
|
+
exposedProjections.push({
|
|
60
|
+
toolName: tool.name,
|
|
61
|
+
pluginId: input.sourcePluginMetadata.pluginId,
|
|
62
|
+
procedureKey: input.procedureKey,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return { toolExtensionPoint, projectionExtensionPoint, exposedProjections };
|
|
68
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import { createAiToolRegistry, type RegisteredAiTool } from "./tool-registry";
|
|
5
|
+
import { createAiToolResolver, principalSatisfiesRules } from "./resolver";
|
|
6
|
+
|
|
7
|
+
function tool(
|
|
8
|
+
name: string,
|
|
9
|
+
requiredAccessRules: string[],
|
|
10
|
+
): RegisteredAiTool {
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
description: name,
|
|
14
|
+
effect: "read",
|
|
15
|
+
input: z.object({}),
|
|
16
|
+
requiredAccessRules,
|
|
17
|
+
execute: () => Promise.resolve({}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function userWith(accessRules: string[]): AuthUser {
|
|
22
|
+
return { type: "user", id: "u1", accessRules };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("createAiToolResolver.resolveTools", () => {
|
|
26
|
+
test("a principal lacking automation.manage never sees automation.propose", () => {
|
|
27
|
+
const registry = createAiToolRegistry();
|
|
28
|
+
registry.register(
|
|
29
|
+
tool("automation.propose", ["automation.automation.manage"]),
|
|
30
|
+
);
|
|
31
|
+
registry.register(tool("incident.list", ["incident.incident.read"]));
|
|
32
|
+
const resolver = createAiToolResolver({ registry });
|
|
33
|
+
|
|
34
|
+
const principal = userWith(["incident.incident.read"]);
|
|
35
|
+
const names = resolver.resolveTools(principal).map((t) => t.name);
|
|
36
|
+
|
|
37
|
+
expect(names).toEqual(["incident.list"]);
|
|
38
|
+
expect(names).not.toContain("automation.propose");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an admin (accessRules ['*']) sees all tools", () => {
|
|
42
|
+
const registry = createAiToolRegistry();
|
|
43
|
+
registry.register(
|
|
44
|
+
tool("automation.propose", ["automation.automation.manage"]),
|
|
45
|
+
);
|
|
46
|
+
registry.register(tool("incident.list", ["incident.incident.read"]));
|
|
47
|
+
const resolver = createAiToolResolver({ registry });
|
|
48
|
+
|
|
49
|
+
const names = resolver
|
|
50
|
+
.resolveTools(userWith(["*"]))
|
|
51
|
+
.map((t) => t.name)
|
|
52
|
+
.sort();
|
|
53
|
+
|
|
54
|
+
expect(names).toEqual(["automation.propose", "incident.list"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a service principal (no access rules) sees no tools", () => {
|
|
58
|
+
const registry = createAiToolRegistry();
|
|
59
|
+
registry.register(tool("incident.list", ["incident.incident.read"]));
|
|
60
|
+
const resolver = createAiToolResolver({ registry });
|
|
61
|
+
|
|
62
|
+
const service: AuthUser = { type: "service", pluginId: "automation" };
|
|
63
|
+
expect(resolver.resolveTools(service)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a tool requiring MULTIPLE rules needs ALL of them", () => {
|
|
67
|
+
const registry = createAiToolRegistry();
|
|
68
|
+
registry.register(
|
|
69
|
+
tool("multi", ["incident.incident.read", "catalog.system.read"]),
|
|
70
|
+
);
|
|
71
|
+
const resolver = createAiToolResolver({ registry });
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
resolver.resolveTools(userWith(["incident.incident.read"])),
|
|
75
|
+
).toEqual([]);
|
|
76
|
+
expect(
|
|
77
|
+
resolver
|
|
78
|
+
.resolveTools(
|
|
79
|
+
userWith(["incident.incident.read", "catalog.system.read"]),
|
|
80
|
+
)
|
|
81
|
+
.map((t) => t.name),
|
|
82
|
+
).toEqual(["multi"]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("isAllowed mirrors autoAuthMiddleware's global-rule check", () => {
|
|
87
|
+
// Replicates the EXACT predicate autoAuthMiddleware applies to global rules
|
|
88
|
+
// (rpc.ts:258-260): rules.includes("*") || rules.includes(qualifiedId).
|
|
89
|
+
function middlewareWouldAllow(
|
|
90
|
+
rules: string[],
|
|
91
|
+
requiredAccessRules: string[],
|
|
92
|
+
): boolean {
|
|
93
|
+
return requiredAccessRules.every(
|
|
94
|
+
(rule) => rules.includes("*") || rules.includes(rule),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ruleUniverse = ["a.read", "b.read", "c.manage"];
|
|
99
|
+
const principalSets: string[][] = [
|
|
100
|
+
[],
|
|
101
|
+
["*"],
|
|
102
|
+
["a.read"],
|
|
103
|
+
["a.read", "b.read"],
|
|
104
|
+
["c.manage"],
|
|
105
|
+
["a.read", "b.read", "c.manage"],
|
|
106
|
+
];
|
|
107
|
+
const toolRuleSets: string[][] = [
|
|
108
|
+
[],
|
|
109
|
+
["a.read"],
|
|
110
|
+
["a.read", "b.read"],
|
|
111
|
+
["c.manage"],
|
|
112
|
+
["a.read", "c.manage"],
|
|
113
|
+
ruleUniverse,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
test("isAllowed == middleware decision for the full matrix", () => {
|
|
117
|
+
const registry = createAiToolRegistry();
|
|
118
|
+
const resolver = createAiToolResolver({ registry });
|
|
119
|
+
|
|
120
|
+
for (const rules of principalSets) {
|
|
121
|
+
for (const required of toolRuleSets) {
|
|
122
|
+
const principal = userWith(rules);
|
|
123
|
+
const t = tool("t", required);
|
|
124
|
+
const expected = middlewareWouldAllow(rules, required);
|
|
125
|
+
expect(resolver.isAllowed({ principal, tool: t })).toBe(expected);
|
|
126
|
+
expect(
|
|
127
|
+
principalSatisfiesRules({
|
|
128
|
+
principalAccessRules: rules,
|
|
129
|
+
requiredAccessRules: required,
|
|
130
|
+
}),
|
|
131
|
+
).toBe(expected);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("narrowing can never widen: a subset principal allows a subset of tools", () => {
|
|
137
|
+
const registry = createAiToolRegistry();
|
|
138
|
+
registry.register(tool("t1", ["a.read"]));
|
|
139
|
+
registry.register(tool("t2", ["b.read"]));
|
|
140
|
+
registry.register(tool("t3", ["c.manage"]));
|
|
141
|
+
const resolver = createAiToolResolver({ registry });
|
|
142
|
+
|
|
143
|
+
const full = userWith(["a.read", "b.read", "c.manage"]);
|
|
144
|
+
const narrowed = userWith(["a.read"]); // a narrowed token: strict subset
|
|
145
|
+
|
|
146
|
+
const fullNames = new Set(resolver.resolveTools(full).map((t) => t.name));
|
|
147
|
+
const narrowedNames = resolver.resolveTools(narrowed).map((t) => t.name);
|
|
148
|
+
|
|
149
|
+
// Every tool the narrowed principal sees is also seen by the full
|
|
150
|
+
// principal — narrowing only ever removes tools.
|
|
151
|
+
for (const name of narrowedNames) {
|
|
152
|
+
expect(fullNames.has(name)).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
expect(narrowedNames).toEqual(["t1"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AuthUser } from "@checkstack/backend-api";
|
|
2
|
+
import type { RegisteredAiTool } from "./tool-registry";
|
|
3
|
+
import type { AiToolRegistry } from "./tool-registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the subset of registered tools a principal may see / call.
|
|
7
|
+
*
|
|
8
|
+
* The predicate is intentionally IDENTICAL to the global-rule check
|
|
9
|
+
* `autoAuthMiddleware` applies (rpc.ts:258-260): a tool is allowed iff EVERY
|
|
10
|
+
* `requiredAccessRules` entry is satisfied by the principal's `accessRules`,
|
|
11
|
+
* with the `"*"` admin escape. This guarantees the resolver can never surface a
|
|
12
|
+
* tool the handler would then reject for a global rule, and — crucially — can
|
|
13
|
+
* never WIDEN a principal: a scope-narrowed principal carries a smaller
|
|
14
|
+
* `accessRules` set, and the intersection only ever shrinks the visible tools.
|
|
15
|
+
*
|
|
16
|
+
* Team reach is NOT pre-filtered here. Instance (team-scoped) rules are
|
|
17
|
+
* enforced per-call handler-side via the existing S2S `checkResourceTeamAccess`,
|
|
18
|
+
* so the resolver filters by the access-rule VOCABULARY only and the surfaced
|
|
19
|
+
* tool set matches exactly what the principal could invoke in the UI.
|
|
20
|
+
*/
|
|
21
|
+
export interface AiToolResolver {
|
|
22
|
+
resolveTools(principal: AuthUser): RegisteredAiTool[];
|
|
23
|
+
isAllowed(args: { principal: AuthUser; tool: RegisteredAiTool }): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pure predicate mirroring the middleware's global-rule check. Exported so
|
|
28
|
+
* tests can assert it equals `autoAuthMiddleware`'s behaviour for an arbitrary
|
|
29
|
+
* (rules, requiredAccessRules) matrix.
|
|
30
|
+
*/
|
|
31
|
+
export function principalSatisfiesRules({
|
|
32
|
+
principalAccessRules,
|
|
33
|
+
requiredAccessRules,
|
|
34
|
+
}: {
|
|
35
|
+
principalAccessRules: readonly string[];
|
|
36
|
+
requiredAccessRules: readonly string[];
|
|
37
|
+
}): boolean {
|
|
38
|
+
return requiredAccessRules.every(
|
|
39
|
+
(rule) =>
|
|
40
|
+
principalAccessRules.includes("*") ||
|
|
41
|
+
principalAccessRules.includes(rule),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Single-tool authorization gate. Services bypass the registry entirely (they
|
|
47
|
+
* are trusted S2S callers and never drive the model); a service principal has
|
|
48
|
+
* no access-rule set, so it is never granted a tool here.
|
|
49
|
+
*/
|
|
50
|
+
export function isToolAllowed({
|
|
51
|
+
principal,
|
|
52
|
+
tool,
|
|
53
|
+
}: {
|
|
54
|
+
principal: AuthUser;
|
|
55
|
+
tool: RegisteredAiTool;
|
|
56
|
+
}): boolean {
|
|
57
|
+
const principalAccessRules =
|
|
58
|
+
"accessRules" in principal ? (principal.accessRules ?? []) : [];
|
|
59
|
+
return principalSatisfiesRules({
|
|
60
|
+
principalAccessRules,
|
|
61
|
+
requiredAccessRules: tool.requiredAccessRules,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createAiToolResolver({
|
|
66
|
+
registry,
|
|
67
|
+
}: {
|
|
68
|
+
registry: AiToolRegistry;
|
|
69
|
+
}): AiToolResolver {
|
|
70
|
+
return {
|
|
71
|
+
isAllowed: isToolAllowed,
|
|
72
|
+
resolveTools(principal: AuthUser): RegisteredAiTool[] {
|
|
73
|
+
return registry
|
|
74
|
+
.getTools()
|
|
75
|
+
.filter((tool) => isToolAllowed({ principal, tool }));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { coerceConversationModel } from "./router";
|
|
3
|
+
import type { ChatIntegrationLister } from "./router";
|
|
4
|
+
|
|
5
|
+
function lister(
|
|
6
|
+
integrations: Array<{
|
|
7
|
+
connectionId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
defaultModel: string;
|
|
10
|
+
availableModels?: string[];
|
|
11
|
+
}>,
|
|
12
|
+
): ChatIntegrationLister {
|
|
13
|
+
return { list: () => Promise.resolve(integrations) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const conn = {
|
|
17
|
+
connectionId: "ai.openai-compatible.c1",
|
|
18
|
+
name: "OpenAI",
|
|
19
|
+
defaultModel: "gpt-4o-mini",
|
|
20
|
+
availableModels: ["gpt-4o-mini", "gpt-4o"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("coerceConversationModel (P4 review item 1 — server-side model control)", () => {
|
|
24
|
+
test("honours an allowlisted model", async () => {
|
|
25
|
+
const model = await coerceConversationModel({
|
|
26
|
+
integrations: lister([conn]),
|
|
27
|
+
integrationId: conn.connectionId,
|
|
28
|
+
model: "gpt-4o",
|
|
29
|
+
});
|
|
30
|
+
expect(model).toBe("gpt-4o");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("coerces an out-of-allowlist model to defaultModel (untrusted wire input)", async () => {
|
|
34
|
+
const model = await coerceConversationModel({
|
|
35
|
+
integrations: lister([conn]),
|
|
36
|
+
integrationId: conn.connectionId,
|
|
37
|
+
model: "evil-model",
|
|
38
|
+
});
|
|
39
|
+
expect(model).toBe("gpt-4o-mini");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an empty allowlist allows any model (free-text providers)", async () => {
|
|
43
|
+
const free = { ...conn, availableModels: undefined };
|
|
44
|
+
const model = await coerceConversationModel({
|
|
45
|
+
integrations: lister([free]),
|
|
46
|
+
integrationId: free.connectionId,
|
|
47
|
+
model: "llama3",
|
|
48
|
+
});
|
|
49
|
+
expect(model).toBe("llama3");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns undefined when no model is requested", async () => {
|
|
53
|
+
const model = await coerceConversationModel({
|
|
54
|
+
integrations: lister([conn]),
|
|
55
|
+
integrationId: conn.connectionId,
|
|
56
|
+
model: undefined,
|
|
57
|
+
});
|
|
58
|
+
expect(model).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("drops the model when the integration cannot be resolved (no validation possible)", async () => {
|
|
62
|
+
const model = await coerceConversationModel({
|
|
63
|
+
integrations: lister([conn]),
|
|
64
|
+
integrationId: "unknown.connection",
|
|
65
|
+
model: "gpt-4o",
|
|
66
|
+
});
|
|
67
|
+
expect(model).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("drops the model when no integration id is given", async () => {
|
|
71
|
+
const model = await coerceConversationModel({
|
|
72
|
+
integrations: lister([conn]),
|
|
73
|
+
integrationId: undefined,
|
|
74
|
+
model: "gpt-4o",
|
|
75
|
+
});
|
|
76
|
+
expect(model).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|