@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/drizzle/0000_productive_jackpot.sql +26 -0
  3. package/drizzle/0001_puzzling_purple_man.sql +26 -0
  4. package/drizzle/0002_sparkling_paper_doll.sql +15 -0
  5. package/drizzle/0003_married_senator_kelly.sql +1 -0
  6. package/drizzle/0004_crazy_miek.sql +2 -0
  7. package/drizzle/0005_tearful_randall_flagg.sql +1 -0
  8. package/drizzle/meta/0000_snapshot.json +232 -0
  9. package/drizzle/meta/0001_snapshot.json +434 -0
  10. package/drizzle/meta/0002_snapshot.json +551 -0
  11. package/drizzle/meta/0003_snapshot.json +557 -0
  12. package/drizzle/meta/0004_snapshot.json +573 -0
  13. package/drizzle/meta/0005_snapshot.json +574 -0
  14. package/drizzle/meta/_journal.json +48 -0
  15. package/drizzle.config.ts +7 -0
  16. package/package.json +42 -0
  17. package/src/agent-runner.test.ts +262 -0
  18. package/src/agent-runner.ts +262 -0
  19. package/src/chat/agent-loop.test.ts +119 -0
  20. package/src/chat/agent-loop.ts +73 -0
  21. package/src/chat/auto-apply.test.ts +237 -0
  22. package/src/chat/chat-handler.ts +111 -0
  23. package/src/chat/chat-service.streamturn.test.ts +417 -0
  24. package/src/chat/chat-service.test.ts +250 -0
  25. package/src/chat/chat-service.ts +923 -0
  26. package/src/chat/classifier-service.ts +64 -0
  27. package/src/chat/classifier.logic.test.ts +92 -0
  28. package/src/chat/classifier.logic.ts +71 -0
  29. package/src/chat/conversation-store.it.test.ts +203 -0
  30. package/src/chat/conversation-store.test.ts +248 -0
  31. package/src/chat/conversation-store.ts +237 -0
  32. package/src/chat/decision.logic.test.ts +45 -0
  33. package/src/chat/decision.logic.ts +54 -0
  34. package/src/chat/llm-provider.test.ts +63 -0
  35. package/src/chat/llm-provider.ts +67 -0
  36. package/src/chat/model-error.logic.test.ts +60 -0
  37. package/src/chat/model-error.logic.ts +65 -0
  38. package/src/chat/normalize-messages.logic.test.ts +101 -0
  39. package/src/chat/normalize-messages.logic.ts +65 -0
  40. package/src/chat/permission-mode.logic.test.ts +70 -0
  41. package/src/chat/permission-mode.logic.ts +45 -0
  42. package/src/chat/read-invoker.ts +72 -0
  43. package/src/chat/replay.test.ts +174 -0
  44. package/src/chat/scrub-content.test.ts +183 -0
  45. package/src/chat/scrub-content.ts +154 -0
  46. package/src/chat/sdk-tools.test.ts +168 -0
  47. package/src/chat/sdk-tools.ts +181 -0
  48. package/src/chat/title-service.test.ts +146 -0
  49. package/src/chat/title-service.ts +111 -0
  50. package/src/chat/title.logic.test.ts +98 -0
  51. package/src/chat/title.logic.ts +102 -0
  52. package/src/extension-points.ts +41 -0
  53. package/src/generated/docs-index.ts +3020 -0
  54. package/src/hardening/handler-authz.test.ts +282 -0
  55. package/src/hardening/no-secret-leak.test.ts +303 -0
  56. package/src/hooks.ts +33 -0
  57. package/src/index.ts +542 -0
  58. package/src/mcp/connection-registry.test.ts +25 -0
  59. package/src/mcp/connection-registry.ts +54 -0
  60. package/src/mcp/mcp-conformance.it.test.ts +128 -0
  61. package/src/mcp/server.test.ts +285 -0
  62. package/src/mcp/server.ts +300 -0
  63. package/src/mcp/tool-invoker.ts +65 -0
  64. package/src/openai-provider.test.ts +64 -0
  65. package/src/openai-provider.ts +146 -0
  66. package/src/projection.test.ts +97 -0
  67. package/src/projection.ts +132 -0
  68. package/src/propose-apply/args-hash.test.ts +26 -0
  69. package/src/propose-apply/args-hash.ts +30 -0
  70. package/src/propose-apply/service.test.ts +423 -0
  71. package/src/propose-apply/service.ts +419 -0
  72. package/src/propose-apply/store.test.ts +136 -0
  73. package/src/propose-apply/store.ts +224 -0
  74. package/src/propose-apply/token.test.ts +52 -0
  75. package/src/propose-apply/token.ts +71 -0
  76. package/src/rate-limit/spend-ledger.it.test.ts +224 -0
  77. package/src/rate-limit/spend-ledger.test.ts +176 -0
  78. package/src/rate-limit/spend-ledger.ts +162 -0
  79. package/src/rate-limit/tool-budget.it.test.ts +173 -0
  80. package/src/rate-limit/tool-budget.test.ts +58 -0
  81. package/src/rate-limit/tool-budget.ts +107 -0
  82. package/src/registry-wiring.test.ts +131 -0
  83. package/src/registry-wiring.ts +68 -0
  84. package/src/resolver.test.ts +156 -0
  85. package/src/resolver.ts +78 -0
  86. package/src/router.test.ts +78 -0
  87. package/src/router.ts +345 -0
  88. package/src/schema.ts +284 -0
  89. package/src/serializer.test.ts +88 -0
  90. package/src/serializer.ts +42 -0
  91. package/src/tool-registry.ts +58 -0
  92. package/src/tools/composite-tools.ts +24 -0
  93. package/src/tools/docs-tools.test.ts +150 -0
  94. package/src/tools/docs-tools.ts +115 -0
  95. package/src/tools/probe-url.test.ts +51 -0
  96. package/src/tools/probe-url.ts +146 -0
  97. package/src/tools/rank-docs.test.ts +153 -0
  98. package/src/tools/rank-docs.ts +209 -0
  99. package/src/tools/script-context-extract.test.ts +93 -0
  100. package/src/tools/script-context-extract.ts +283 -0
  101. package/src/tools/ssrf-guard.test.ts +69 -0
  102. package/src/tools/ssrf-guard.ts +108 -0
  103. package/src/tools/tool-set.e2e.test.ts +64 -0
  104. package/src/user-rpc-client.test.ts +45 -0
  105. package/src/user-rpc-client.ts +60 -0
  106. package/tsconfig.json +26 -0
@@ -0,0 +1,283 @@
1
+ import type {
2
+ ScriptContextKind,
3
+ ShellEnvVar,
4
+ } from "@checkstack/ai-common";
5
+
6
+ /**
7
+ * Pure extraction of per-context SDK symbols from the generated editor bundle
8
+ * (§3.1). The single source of truth for script SDK symbols is the GENERATED
9
+ * `SDK_EDITOR_BUNDLE_DTS` string (`@checkstack/sdk/editor-bundle`) - the SAME
10
+ * text Monaco mounts. This module pulls the `declare module "<name>" { ... }`
11
+ * block for a context out of that string by region extraction (no TypeScript
12
+ * compiler dependency), so it cannot drift from the editor (both derive from
13
+ * the one generated bundle).
14
+ *
15
+ * For shell contexts there is no SDK module: the "symbols" are the reserved
16
+ * `CHECKSTACK_*` env vars the runner injects, shipped here as a static
17
+ * descriptor table guarded by a drift test (§3.1 / OQ-2).
18
+ */
19
+
20
+ /** Per-context static descriptor that drives extraction + tool output. */
21
+ export interface ScriptContextDescriptor {
22
+ context: ScriptContextKind;
23
+ language: "typescript" | "shell";
24
+ /** SDK module the script imports from (TS contexts only). */
25
+ sdkModule?: string;
26
+ /** The define-helper name (TS contexts only). */
27
+ helper?: string;
28
+ /** Whether managed npm packages are importable. */
29
+ allowsManagedPackages: boolean;
30
+ }
31
+
32
+ export const SCRIPT_CONTEXT_DESCRIPTORS: Readonly<
33
+ Record<ScriptContextKind, ScriptContextDescriptor>
34
+ > = {
35
+ "healthcheck-script": {
36
+ context: "healthcheck-script",
37
+ language: "typescript",
38
+ sdkModule: "@checkstack/sdk/healthcheck",
39
+ helper: "defineHealthCheck",
40
+ allowsManagedPackages: true,
41
+ },
42
+ "automation-action-script": {
43
+ context: "automation-action-script",
44
+ language: "typescript",
45
+ sdkModule: "@checkstack/sdk/integration",
46
+ helper: "defineIntegration",
47
+ allowsManagedPackages: true,
48
+ },
49
+ "healthcheck-shell": {
50
+ context: "healthcheck-shell",
51
+ language: "shell",
52
+ allowsManagedPackages: false,
53
+ },
54
+ "automation-action-shell": {
55
+ context: "automation-action-shell",
56
+ language: "shell",
57
+ allowsManagedPackages: false,
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Reserved `CHECKSTACK_*` env vars the shell health-check collector exposes.
63
+ * Hand-maintained here (cross-plugin import is deliberately avoided by the
64
+ * codebase) and guarded by `shell-env-table.test.ts`, which asserts these match
65
+ * `buildShellRunContextEnv`'s output for a representative sample (OQ-2).
66
+ */
67
+ export const HEALTHCHECK_SHELL_ENV: readonly ShellEnvVar[] = [
68
+ {
69
+ name: "CHECKSTACK_CHECK_ID",
70
+ description: "The health check configuration id.",
71
+ },
72
+ {
73
+ name: "CHECKSTACK_CHECK_NAME",
74
+ description: "The health check's display name (falls back to the id).",
75
+ },
76
+ {
77
+ name: "CHECKSTACK_CHECK_INTERVAL_SECONDS",
78
+ description: "The configured run interval, in seconds.",
79
+ },
80
+ { name: "CHECKSTACK_SYSTEM_ID", description: "The system id." },
81
+ {
82
+ name: "CHECKSTACK_SYSTEM_NAME",
83
+ description: "The system's display name (falls back to the id).",
84
+ },
85
+ { name: "CHECKSTACK_ENV_ID", description: "The resolved environment id." },
86
+ {
87
+ name: "CHECKSTACK_ENV_NAME",
88
+ description: "The resolved environment's display name.",
89
+ },
90
+ {
91
+ name: "CHECKSTACK_ENV_<FIELD>",
92
+ description:
93
+ "One var per environment custom-field: the field key uppercased with non-alphanumeric runs collapsed to '_' (e.g. region -> CHECKSTACK_ENV_REGION).",
94
+ },
95
+ ];
96
+
97
+ /**
98
+ * Reserved `CHECKSTACK_*` env vars a `run_shell` automation action exposes.
99
+ * The runner flattens the trigger event + scope into `CHECKSTACK_*` vars.
100
+ */
101
+ export const AUTOMATION_SHELL_ENV: readonly ShellEnvVar[] = [
102
+ {
103
+ name: "CHECKSTACK_EVENT_ID",
104
+ description: 'Fully-qualified event id, e.g. "incident.created".',
105
+ },
106
+ {
107
+ name: "CHECKSTACK_EVENT_PAYLOAD",
108
+ description: "The triggering event payload as a JSON string.",
109
+ },
110
+ {
111
+ name: "CHECKSTACK_TRIGGER_EVENT",
112
+ description: "The event id that triggered this run.",
113
+ },
114
+ ];
115
+
116
+ const SHELL_ENV_BY_CONTEXT: Partial<
117
+ Record<ScriptContextKind, readonly ShellEnvVar[]>
118
+ > = {
119
+ "healthcheck-shell": HEALTHCHECK_SHELL_ENV,
120
+ "automation-action-shell": AUTOMATION_SHELL_ENV,
121
+ };
122
+
123
+ /** Thrown for an unknown context or a missing `declare module` block. */
124
+ export class ScriptContextExtractionError extends Error {
125
+ constructor(message: string) {
126
+ super(message);
127
+ this.name = "ScriptContextExtractionError";
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Extract the `declare module "<moduleName>" { ... }` block from the bundle
133
+ * text by brace matching. Pure: no compiler, no I/O. Returns the full block
134
+ * (including the `declare module` header and the matching closing brace).
135
+ */
136
+ export function extractDeclareModuleBlock({
137
+ bundle,
138
+ moduleName,
139
+ }: {
140
+ bundle: string;
141
+ moduleName: string;
142
+ }): string {
143
+ const header = `declare module "${moduleName}"`;
144
+ const headerIndex = bundle.indexOf(header);
145
+ if (headerIndex === -1) {
146
+ throw new ScriptContextExtractionError(
147
+ String.raw`SDK editor bundle has no "declare module \"${moduleName}\"" block.`,
148
+ );
149
+ }
150
+ const openBrace = bundle.indexOf("{", headerIndex);
151
+ if (openBrace === -1) {
152
+ throw new ScriptContextExtractionError(
153
+ String.raw`Malformed "declare module \"${moduleName}\"" block: no opening brace.`,
154
+ );
155
+ }
156
+ let depth = 0;
157
+ for (let i = openBrace; i < bundle.length; i++) {
158
+ const ch = bundle[i];
159
+ if (ch === "{") depth++;
160
+ else if (ch === "}") {
161
+ depth--;
162
+ if (depth === 0) {
163
+ return bundle.slice(headerIndex, i + 1);
164
+ }
165
+ }
166
+ }
167
+ throw new ScriptContextExtractionError(
168
+ String.raw`Malformed "declare module \"${moduleName}\"" block: unbalanced braces.`,
169
+ );
170
+ }
171
+
172
+ /** Render the shell env table into a readable declarations string. */
173
+ export function renderShellEnvDeclarations(
174
+ envVars: readonly ShellEnvVar[],
175
+ ): string {
176
+ return envVars
177
+ .map((v) => `# ${v.name}\n# ${v.description}`)
178
+ .join("\n");
179
+ }
180
+
181
+ /** A minimal runnable starter the model can adapt, per context. */
182
+ export function buildStarterExample(context: ScriptContextKind): string {
183
+ switch (context) {
184
+ case "healthcheck-script": {
185
+ return [
186
+ 'import { defineHealthCheck } from "@checkstack/sdk/healthcheck";',
187
+ "",
188
+ "export default defineHealthCheck(async (ctx) => {",
189
+ " const res = await fetch(String(ctx.config.url));",
190
+ " return { success: res.ok, message: `HTTP ${res.status}` };",
191
+ "});",
192
+ ].join("\n");
193
+ }
194
+ case "automation-action-script": {
195
+ return [
196
+ 'import { defineIntegration } from "@checkstack/sdk/integration";',
197
+ "",
198
+ "export default defineIntegration(async (ctx) => {",
199
+ " console.log(`event ${ctx.event.eventId}`);",
200
+ " return { id: ctx.event.deliveryId };",
201
+ "});",
202
+ ].join("\n");
203
+ }
204
+ case "healthcheck-shell": {
205
+ return [
206
+ "#!/usr/bin/env bash",
207
+ "set -euo pipefail",
208
+ 'echo "checking system $CHECKSTACK_SYSTEM_NAME"',
209
+ "curl -fsS https://example.com/health",
210
+ ].join("\n");
211
+ }
212
+ case "automation-action-shell": {
213
+ return [
214
+ "#!/usr/bin/env bash",
215
+ "set -euo pipefail",
216
+ 'echo "handling event $CHECKSTACK_EVENT_ID"',
217
+ ].join("\n");
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Result of resolving a context's SDK symbols / declarations. */
223
+ export interface ResolvedScriptContext {
224
+ context: ScriptContextKind;
225
+ language: "typescript" | "shell";
226
+ sdkModule?: string;
227
+ helper?: string;
228
+ declarations: string;
229
+ shellEnv?: readonly ShellEnvVar[];
230
+ starterExample: string;
231
+ allowsManagedPackages: boolean;
232
+ }
233
+
234
+ /**
235
+ * Resolve the SDK symbols / imports / type signatures for a script context by
236
+ * PURE extraction from the generated SDK editor bundle. For TS contexts the
237
+ * `declarations` is the matching `declare module` block; for shell contexts it
238
+ * is the rendered `CHECKSTACK_*` env table.
239
+ */
240
+ export function resolveScriptContext({
241
+ context,
242
+ bundle,
243
+ }: {
244
+ context: ScriptContextKind;
245
+ bundle: string;
246
+ }): ResolvedScriptContext {
247
+ const descriptor = SCRIPT_CONTEXT_DESCRIPTORS[context];
248
+ if (!descriptor) {
249
+ throw new ScriptContextExtractionError(`Unknown script context: ${context}`);
250
+ }
251
+
252
+ if (descriptor.language === "shell") {
253
+ const shellEnv = SHELL_ENV_BY_CONTEXT[context] ?? [];
254
+ return {
255
+ context,
256
+ language: "shell",
257
+ declarations: renderShellEnvDeclarations(shellEnv),
258
+ shellEnv,
259
+ starterExample: buildStarterExample(context),
260
+ allowsManagedPackages: descriptor.allowsManagedPackages,
261
+ };
262
+ }
263
+
264
+ // TS context: pull the matching declare-module block from the generated bundle.
265
+ if (!descriptor.sdkModule) {
266
+ throw new ScriptContextExtractionError(
267
+ `Context ${context} is TypeScript but has no SDK module configured.`,
268
+ );
269
+ }
270
+ const declarations = extractDeclareModuleBlock({
271
+ bundle,
272
+ moduleName: descriptor.sdkModule,
273
+ });
274
+ return {
275
+ context,
276
+ language: "typescript",
277
+ sdkModule: descriptor.sdkModule,
278
+ helper: descriptor.helper,
279
+ declarations,
280
+ starterExample: buildStarterExample(context),
281
+ allowsManagedPackages: descriptor.allowsManagedPackages,
282
+ };
283
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isPrivateIp, parseSafeProbeUrl } from "./ssrf-guard";
3
+
4
+ describe("isPrivateIp", () => {
5
+ test("blocks private/reserved IPv4 ranges", () => {
6
+ for (const ip of [
7
+ "127.0.0.1",
8
+ "10.1.2.3",
9
+ "172.16.0.1",
10
+ "172.31.255.255",
11
+ "192.168.1.1",
12
+ "169.254.169.254", // cloud metadata
13
+ "100.64.0.1", // CGNAT
14
+ "0.0.0.0",
15
+ "224.0.0.1", // multicast
16
+ ]) {
17
+ expect(isPrivateIp(ip)).toBe(true);
18
+ }
19
+ });
20
+
21
+ test("allows public IPv4", () => {
22
+ for (const ip of ["8.8.8.8", "1.1.1.1", "93.184.216.34"]) {
23
+ expect(isPrivateIp(ip)).toBe(false);
24
+ }
25
+ });
26
+
27
+ test("blocks private/reserved IPv6 incl. IPv4-mapped private", () => {
28
+ for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12::3", "::ffff:127.0.0.1"]) {
29
+ expect(isPrivateIp(ip)).toBe(true);
30
+ }
31
+ });
32
+
33
+ test("allows public IPv6", () => {
34
+ expect(isPrivateIp("2606:4700:4700::1111")).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("parseSafeProbeUrl", () => {
39
+ test("accepts a public http(s) URL", () => {
40
+ const safe = parseSafeProbeUrl("https://example.com/status");
41
+ expect(safe.hostname).toBe("example.com");
42
+ expect(safe.url.pathname).toBe("/status");
43
+ });
44
+
45
+ test("rejects non-http(s) schemes", () => {
46
+ expect(() => parseSafeProbeUrl("file:///etc/passwd")).toThrow(/http and https/);
47
+ expect(() => parseSafeProbeUrl("ftp://example.com")).toThrow(/http and https/);
48
+ });
49
+
50
+ test("rejects malformed URLs", () => {
51
+ expect(() => parseSafeProbeUrl("not a url")).toThrow(/Invalid URL/);
52
+ });
53
+
54
+ test("rejects loopback + internal hostnames", () => {
55
+ expect(() => parseSafeProbeUrl("http://localhost:8080/")).toThrow(/loopback/);
56
+ expect(() => parseSafeProbeUrl("http://foo.localhost/")).toThrow(/loopback/);
57
+ expect(() =>
58
+ parseSafeProbeUrl("http://metadata.google.internal/"),
59
+ ).toThrow(/loopback|internal/);
60
+ });
61
+
62
+ test("rejects private IP literals (v4 + v6)", () => {
63
+ expect(() => parseSafeProbeUrl("http://169.254.169.254/latest/meta-data")).toThrow(
64
+ /private\/reserved/,
65
+ );
66
+ expect(() => parseSafeProbeUrl("http://10.0.0.5/")).toThrow(/private\/reserved/);
67
+ expect(() => parseSafeProbeUrl("http://[::1]:9000/")).toThrow(/private\/reserved/);
68
+ });
69
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * SSRF guards for the `ai.probeUrl` tool. The probe issues an outbound HTTP
3
+ * request FROM THE CORE BACKEND, so it must never be steerable at internal
4
+ * services, the loopback interface, or the cloud metadata endpoint. These pure
5
+ * helpers (URL shape + IP-range classification) are unit-tested; the network
6
+ * call itself lives in `probe-url.ts` and uses them to validate the URL AND
7
+ * every DNS-resolved IP before connecting.
8
+ */
9
+
10
+ /** Max response body we read from a probe (avoid memory blow-ups). */
11
+ export const PROBE_MAX_BODY_BYTES = 256 * 1024;
12
+ /** Probe request timeout. */
13
+ export const PROBE_TIMEOUT_MS = 5000;
14
+
15
+ /** Hostnames that must never be probed regardless of DNS. */
16
+ const BLOCKED_HOSTNAMES = new Set([
17
+ "localhost",
18
+ "metadata.google.internal",
19
+ ]);
20
+
21
+ /**
22
+ * Classify an IPv4 literal as private/reserved (must be blocked). Covers the
23
+ * ranges an SSRF would target: loopback, RFC1918, link-local (incl. the cloud
24
+ * metadata 169.254.169.254), CGNAT, "this host", and broadcast.
25
+ */
26
+ function isPrivateIpv4(ip: string): boolean {
27
+ const parts = ip.split(".");
28
+ if (parts.length !== 4) return false;
29
+ const octets = parts.map(Number);
30
+ if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false;
31
+ const [a, b] = octets;
32
+ if (a === 0) return true; // 0.0.0.0/8 "this host"
33
+ if (a === 10) return true; // 10.0.0.0/8
34
+ if (a === 127) return true; // 127.0.0.0/8 loopback
35
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + metadata
36
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
37
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
38
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
39
+ if (a === 192 && b === 0) return true; // 192.0.0.0/24 + 192.0.2.0/24 (test nets)
40
+ if (a >= 224) return true; // multicast + reserved/broadcast
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Classify an IPv6 literal as private/reserved. Handles loopback, ULA, link-
46
+ * local, unspecified, and IPv4-mapped/embedded addresses (which would otherwise
47
+ * smuggle a private IPv4 past an IPv6 check).
48
+ */
49
+ function isPrivateIpv6(ip: string): boolean {
50
+ const addr = ip.toLowerCase().split("%")[0]; // strip zone id
51
+ if (addr === "::1" || addr === "::") return true; // loopback / unspecified
52
+ // IPv4-mapped (::ffff:a.b.c.d) or IPv4-compatible: validate the embedded v4.
53
+ const v4Match = addr.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
54
+ if (v4Match && isPrivateIpv4(v4Match[1])) return true;
55
+ const head = addr.split(":")[0];
56
+ if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA
57
+ if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb")) {
58
+ return true; // fe80::/10 link-local
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /** True when an IP literal is private/reserved and must NOT be probed. */
64
+ export function isPrivateIp(ip: string): boolean {
65
+ return ip.includes(":") ? isPrivateIpv6(ip) : isPrivateIpv4(ip);
66
+ }
67
+
68
+ /** The validated, safe-to-fetch parse of a probe URL. */
69
+ export interface SafeProbeUrl {
70
+ url: URL;
71
+ hostname: string;
72
+ }
73
+
74
+ /**
75
+ * Validate a probe URL's SHAPE (before DNS): it must be a well-formed http(s)
76
+ * URL, not a blocked hostname, and not an IP literal in a private range. Throws
77
+ * with a clear message on rejection. DNS-resolved IPs are checked separately at
78
+ * fetch time via {@link isPrivateIp} (this catches a hostname that resolves to
79
+ * a private IP, which the shape check cannot see).
80
+ */
81
+ export function parseSafeProbeUrl(raw: string): SafeProbeUrl {
82
+ let url: URL;
83
+ try {
84
+ url = new URL(raw);
85
+ } catch {
86
+ throw new Error(`Invalid URL: "${raw}".`);
87
+ }
88
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
89
+ throw new Error(
90
+ `Only http and https URLs can be probed (got "${url.protocol}").`,
91
+ );
92
+ }
93
+ const hostname = url.hostname.replaceAll(/^\[|\]$/g, "").toLowerCase(); // unwrap [::1]
94
+ if (!hostname) {
95
+ throw new Error("URL has no host.");
96
+ }
97
+ if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
98
+ throw new Error(`Refusing to probe a loopback/internal host: "${hostname}".`);
99
+ }
100
+ // If the host is an IP literal, reject private ranges up front.
101
+ const looksLikeIp = /^[\d.]+$/.test(hostname) || hostname.includes(":");
102
+ if (looksLikeIp && isPrivateIp(hostname)) {
103
+ throw new Error(
104
+ `Refusing to probe a private/reserved address: "${hostname}".`,
105
+ );
106
+ }
107
+ return { url, hostname };
108
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AuthUser } from "@checkstack/backend-api";
3
+ import { aiAccess, pluginMetadata as aiPluginMetadata } from "@checkstack/ai-common";
4
+ import { qualifyAccessRuleId } from "@checkstack/common";
5
+ import { createAiToolRegistry } from "../tool-registry";
6
+ import type { AiToolRegistry } from "../tool-registry";
7
+ import { createAiToolResolver } from "../resolver";
8
+ import { buildCompositeTools } from "./composite-tools";
9
+
10
+ const CHAT_READ_RULE = qualifyAccessRuleId(aiPluginMetadata, aiAccess.chatUse);
11
+
12
+ /**
13
+ * ai-backend's OWN platform tools, built from the SAME shared `buildCompositeTools`
14
+ * factory the plugin init uses. After the decouple, these are the genuinely
15
+ * cross-plugin / platform read tools (docs grounding + URL probe) - all gated by
16
+ * the broad `ai.chat.read` surface. Plugin-specific tools (propose/update/delete,
17
+ * capability catalogs, script-context) and projected read tools are owned by and
18
+ * registered FROM their plugins via the extension points, and are covered by
19
+ * those plugins' OWN tests - ai-backend neither builds nor imports them.
20
+ */
21
+ function buildOwnRegistry(): AiToolRegistry {
22
+ const registry = createAiToolRegistry();
23
+ for (const tool of buildCompositeTools()) {
24
+ const name = tool.name.includes(".") ? tool.name : `ai.${tool.name}`;
25
+ registry.register({ ...tool, name });
26
+ }
27
+ return registry;
28
+ }
29
+
30
+ /** An admin-equivalent principal (the `*` escape) - sees every tool. */
31
+ const adminPrincipal: AuthUser = {
32
+ type: "user",
33
+ id: "admin",
34
+ accessRules: ["*"],
35
+ };
36
+
37
+ describe("ai-backend's own platform tool set", () => {
38
+ test("docs + probe tools are registered and qualified", () => {
39
+ const registry = buildOwnRegistry();
40
+ const names = registry.getTools().map((t) => t.name);
41
+ for (const expected of ["ai.searchDocs", "ai.getDoc", "ai.probeUrl"]) {
42
+ expect(names).toContain(expected);
43
+ }
44
+ });
45
+
46
+ test("admin resolves every registered tool", () => {
47
+ const registry = buildOwnRegistry();
48
+ const resolver = createAiToolResolver({ registry });
49
+ expect(resolver.resolveTools(adminPrincipal)).toHaveLength(
50
+ registry.getTools().length,
51
+ );
52
+ });
53
+
54
+ test("every own tool is a read tool gated by the broad chat-read surface", () => {
55
+ // ai-backend's own tools are all platform read tools (docs + probe). A
56
+ // service-client fan-out read tool would need an in-execute re-check; these
57
+ // touch only bundled docs or an outbound fetch, so chat-read is the gate.
58
+ const registry = buildOwnRegistry();
59
+ for (const tool of registry.getTools()) {
60
+ expect(tool.effect).toBe("read");
61
+ expect(tool.requiredAccessRules).toContain(CHAT_READ_RULE);
62
+ }
63
+ });
64
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createUserScopedRpcClient,
4
+ forwardableAuthHeadersFrom,
5
+ } from "./user-rpc-client";
6
+
7
+ describe("forwardableAuthHeadersFrom", () => {
8
+ test("forwards ONLY the session cookie and bearer Authorization", () => {
9
+ const headers = new Headers({
10
+ cookie: "session=abc",
11
+ authorization: "Bearer xyz",
12
+ // Must NOT be forwarded - only auth-bearing headers are.
13
+ "x-forwarded-for": "10.0.0.1",
14
+ "content-type": "application/json",
15
+ });
16
+ expect(forwardableAuthHeadersFrom(headers)).toEqual({
17
+ cookie: "session=abc",
18
+ authorization: "Bearer xyz",
19
+ });
20
+ });
21
+
22
+ test("omits absent headers (no empty values)", () => {
23
+ expect(forwardableAuthHeadersFrom(new Headers({ cookie: "s=1" }))).toEqual({
24
+ cookie: "s=1",
25
+ });
26
+ expect(
27
+ forwardableAuthHeadersFrom(new Headers({ authorization: "Bearer t" })),
28
+ ).toEqual({ authorization: "Bearer t" });
29
+ });
30
+
31
+ test("returns an empty object for undefined or empty headers", () => {
32
+ expect(forwardableAuthHeadersFrom(undefined)).toEqual({});
33
+ expect(forwardableAuthHeadersFrom(new Headers())).toEqual({});
34
+ });
35
+ });
36
+
37
+ describe("createUserScopedRpcClient", () => {
38
+ test("returns an RpcClient with a forPlugin accessor (no network until used)", () => {
39
+ const client = createUserScopedRpcClient({
40
+ internalUrl: "http://localhost:3000",
41
+ forwardHeaders: { cookie: "session=abc" },
42
+ });
43
+ expect(typeof client.forPlugin).toBe("function");
44
+ });
45
+ });
@@ -0,0 +1,60 @@
1
+ import { createORPCClient } from "@orpc/client";
2
+ import { RPCLink } from "@orpc/client/fetch";
3
+ import type { RpcClient } from "@checkstack/backend-api";
4
+
5
+ /**
6
+ * Build a USER-SCOPED `RpcClient` for an AI tool to call plugin procedures AS
7
+ * THE ORIGINATING USER, never as a trusted service.
8
+ *
9
+ * The trusted `coreServices.rpcClient` injects a service JWT, and
10
+ * `autoAuthMiddleware` SHORT-CIRCUITS all access-rule, single-resource, and
11
+ * team-scope checks for service principals. So a tool calling the trusted client
12
+ * would bypass the user's authorization and could read/mutate resources the user
13
+ * cannot - a privilege-escalation path. Instead, this client re-enters the live
14
+ * router at `/api` forwarding the user's OWN auth (session cookie and/or bearer),
15
+ * so the procedure re-authenticates to the SAME principal and runs
16
+ * `autoAuthMiddleware` (incl. per-resource/team `instanceAccess`) exactly as a
17
+ * direct UI/RPC call would. It mirrors `createChatReadInvoker` /
18
+ * `mcp/tool-invoker`, but presents the full typed `RpcClient.forPlugin(...)`
19
+ * surface so a tool's `execute`/`dryRun` uses it transparently.
20
+ *
21
+ * IMPORTANT: only the user's forwardable auth headers (cookie / Authorization)
22
+ * are forwarded - never arbitrary client headers and never the service JWT.
23
+ */
24
+ export function createUserScopedRpcClient({
25
+ internalUrl,
26
+ forwardHeaders,
27
+ }: {
28
+ internalUrl: string;
29
+ forwardHeaders: Record<string, string>;
30
+ }): RpcClient {
31
+ const link = new RPCLink({
32
+ url: `${internalUrl}/api`,
33
+ headers: forwardHeaders,
34
+ });
35
+ const client = createORPCClient(link);
36
+ return {
37
+ forPlugin(def) {
38
+ // Same accessor shape as the trusted client in core-services; typing is
39
+ // provided by the RpcClient interface (InferClient<T>).
40
+ return (client as Record<string, unknown>)[def.pluginId] as never;
41
+ },
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Extract the forwardable auth headers from a `Headers` view (the oRPC handler
47
+ * `context.requestHeaders` for the deferred applyTool/proposeTool path). Only the
48
+ * session cookie and bearer Authorization are forwarded.
49
+ */
50
+ export function forwardableAuthHeadersFrom(
51
+ headers: Headers | undefined,
52
+ ): Record<string, string> {
53
+ const out: Record<string, string> = {};
54
+ if (!headers) return out;
55
+ const cookie = headers.get("cookie");
56
+ if (cookie) out.cookie = cookie;
57
+ const auth = headers.get("authorization");
58
+ if (auth) out.authorization = auth;
59
+ return out;
60
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../ai-common"
9
+ },
10
+ {
11
+ "path": "../backend-api"
12
+ },
13
+ {
14
+ "path": "../common"
15
+ },
16
+ {
17
+ "path": "../drizzle-helper"
18
+ },
19
+ {
20
+ "path": "../integration-backend"
21
+ },
22
+ {
23
+ "path": "../sdk"
24
+ }
25
+ ]
26
+ }