@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,282 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
4
+ import { createAiToolRegistry } from "../tool-registry";
5
+ import type { RegisteredAiTool } from "../tool-registry";
6
+ import { createAiToolResolver } from "../resolver";
7
+ import {
8
+ createProposeApplyService,
9
+ ProposeApplyError,
10
+ } from "../propose-apply/service";
11
+ import type { AiToolCallStore } from "../propose-apply/store";
12
+ import type { AiToolCallRow } from "../schema";
13
+
14
+ /**
15
+ * HARDENING: handler-side authorization holds when the MODEL MISBEHAVES
16
+ * (plan §1.5, §16 matrix #8/#11/#14, risk "Model calls a tool the principal
17
+ * can't use").
18
+ *
19
+ * The LLM is an untrusted caller that happens to be good at picking arguments.
20
+ * These named assertions prove the server refuses a model that "picks" a tool
21
+ * the principal is NOT allowed (or passes bad args) — the refusal is
22
+ * server-side, not a UI hint, and the underlying execute / dryRun is NEVER
23
+ * reached. This complements the per-transport tests (`mcp/server.test.ts` #8,
24
+ * `chat/agent-loop.test.ts` #14): here we assert the SHARED authorization spine
25
+ * (`resolver` + `propose-apply` service) directly, so the invariant holds for
26
+ * any future transport built on the same registry. Pure (no DB, no DOM).
27
+ */
28
+
29
+ // These hardening tests reject at the authz / schema gate BEFORE the dry-run, so
30
+ // the RPC client is never used; a throwing stub satisfies the required arg and
31
+ // proves it is never reached.
32
+ const rpcClient = {
33
+ forPlugin: () => {
34
+ throw new Error("authz gate must reject before any RPC call");
35
+ },
36
+ } as unknown as RpcClient;
37
+
38
+ /** A read tool whose execute must NEVER run for an unauthorized principal. */
39
+ function readTool(
40
+ name: string,
41
+ rule: string,
42
+ onExecute: () => void,
43
+ ): RegisteredAiTool {
44
+ return {
45
+ name,
46
+ description: name,
47
+ effect: "read",
48
+ input: z.object({ q: z.string() }),
49
+ requiredAccessRules: [rule],
50
+ execute: () => {
51
+ onExecute();
52
+ return Promise.resolve({ ok: true });
53
+ },
54
+ };
55
+ }
56
+
57
+ /** A mutating tool whose dryRun must NEVER run for an unauthorized principal. */
58
+ function mutateTool(
59
+ name: string,
60
+ rule: string,
61
+ hooks: { onDryRun: () => void; onExecute: () => void },
62
+ ): RegisteredAiTool {
63
+ return {
64
+ name,
65
+ description: name,
66
+ effect: "mutate",
67
+ input: z.object({ amount: z.number().int().positive() }),
68
+ requiredAccessRules: [rule],
69
+ dryRun: ({ input }) => {
70
+ hooks.onDryRun();
71
+ return Promise.resolve({ summary: "would mutate", payload: input });
72
+ },
73
+ execute: ({ input }) => {
74
+ hooks.onExecute();
75
+ return Promise.resolve(input);
76
+ },
77
+ };
78
+ }
79
+
80
+ /** Minimal in-memory store: enough for the propose path to be exercised. */
81
+ function memStore(): AiToolCallStore {
82
+ let n = 0;
83
+ const rows = new Map<string, AiToolCallRow>();
84
+ const base = (over: Partial<AiToolCallRow>): AiToolCallRow => ({
85
+ id: `row-${++n}`,
86
+ principalKind: "user",
87
+ principalId: "u1",
88
+ transport: "chat",
89
+ conversationId: null,
90
+ toolName: "x",
91
+ effect: "mutate",
92
+ argsHash: "h",
93
+ status: "proposed",
94
+ proposalNonce: null,
95
+ proposalExpiresAt: null,
96
+ resultSnapshot: null,
97
+ proposedPayload: null,
98
+ error: null,
99
+ proposedAt: null,
100
+ appliedAt: null,
101
+ appliedByKind: null,
102
+ appliedById: null,
103
+ createdAt: new Date(),
104
+ ...over,
105
+ });
106
+ return {
107
+ async recordExecuted(args) {
108
+ const row = base({ ...args, conversationId: args.conversationId ?? null, effect: "read", status: "executed" });
109
+ rows.set(row.id, row);
110
+ return row;
111
+ },
112
+ async recordFailed(args) {
113
+ const row = base({ ...args, conversationId: args.conversationId ?? null, status: "failed" });
114
+ rows.set(row.id, row);
115
+ return row;
116
+ },
117
+ async createProposal(args) {
118
+ const nonce = "nonce-fixed";
119
+ const row = base({
120
+ ...args,
121
+ conversationId: args.conversationId ?? null,
122
+ proposalNonce: nonce,
123
+ proposalExpiresAt: new Date(Date.now() + 600_000),
124
+ proposedPayload: args.proposedPayload ?? null,
125
+ resultSnapshot: args.resultSnapshot ?? null,
126
+ proposedAt: new Date(),
127
+ });
128
+ rows.set(row.id, row);
129
+ return { row, nonce };
130
+ },
131
+ async getProposal(id) {
132
+ return rows.get(id);
133
+ },
134
+ async consumeProposal({ rowId }) {
135
+ const row = rows.get(rowId);
136
+ if (!row || row.status !== "proposed") return undefined;
137
+ const applied = { ...row, status: "applied" as const, appliedAt: new Date() };
138
+ rows.set(rowId, applied);
139
+ return applied;
140
+ },
141
+ async expireStaleProposals() {
142
+ return 0;
143
+ },
144
+ };
145
+ }
146
+
147
+ const limited: AuthUser = {
148
+ type: "user",
149
+ id: "u1",
150
+ accessRules: ["incident.incident.read"],
151
+ };
152
+
153
+ describe("HARDENING: a misbehaving model cannot escape the resolver gate", () => {
154
+ test("isAllowed refuses a tool whose rule the principal lacks", () => {
155
+ const registry = createAiToolRegistry();
156
+ let ran = false;
157
+ const adminTool = readTool("ai.secrets", "ai.tools.manage", () => {
158
+ ran = true;
159
+ });
160
+ registry.register(adminTool);
161
+ const resolver = createAiToolResolver({ registry });
162
+
163
+ expect(resolver.isAllowed({ principal: limited, tool: adminTool })).toBe(false);
164
+ // resolveTools never even surfaces it to the model.
165
+ expect(resolver.resolveTools(limited)).toEqual([]);
166
+ expect(ran).toBe(false);
167
+ });
168
+
169
+ test("a service principal (no access rules) is refused every tool", () => {
170
+ const registry = createAiToolRegistry();
171
+ const tool = readTool("incident.list", "incident.incident.read", () => {});
172
+ registry.register(tool);
173
+ const resolver = createAiToolResolver({ registry });
174
+ const service: AuthUser = { type: "service", pluginId: "svc" };
175
+ expect(resolver.isAllowed({ principal: service, tool })).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe("HARDENING: propose refuses a model-picked out-of-scope tool BEFORE dryRun", () => {
180
+ test("an unauthorized mutating tool is refused server-side; dryRun never runs", async () => {
181
+ const registry = createAiToolRegistry();
182
+ let dryRan = false;
183
+ let executed = false;
184
+ const tool = mutateTool("billing.refund", "billing.billing.manage", {
185
+ onDryRun: () => {
186
+ dryRan = true;
187
+ },
188
+ onExecute: () => {
189
+ executed = true;
190
+ },
191
+ });
192
+ registry.register(tool);
193
+ const resolver = createAiToolResolver({ registry });
194
+ const service = createProposeApplyService({
195
+ registry,
196
+ resolver,
197
+ store: memStore(),
198
+ });
199
+
200
+ await expect(
201
+ service.propose({
202
+ principal: limited, // lacks billing.billing.manage
203
+ toolName: "billing.refund",
204
+ input: { amount: 100 },
205
+ transport: "chat",
206
+ rpcClient,
207
+ }),
208
+ ).rejects.toMatchObject({ code: "forbidden" });
209
+
210
+ // The dry-run (and therefore any mutation) was never reached.
211
+ expect(dryRan).toBe(false);
212
+ expect(executed).toBe(false);
213
+ });
214
+ });
215
+
216
+ describe("HARDENING: bad model-supplied args are rejected (no execution on garbage)", () => {
217
+ test("propose rejects args that fail the tool's own zod schema", async () => {
218
+ const registry = createAiToolRegistry();
219
+ let dryRan = false;
220
+ const tool = mutateTool("incident.escalate", "incident.incident.read", {
221
+ onDryRun: () => {
222
+ dryRan = true;
223
+ },
224
+ onExecute: () => {},
225
+ });
226
+ registry.register(tool);
227
+ const resolver = createAiToolResolver({ registry });
228
+ const service = createProposeApplyService({
229
+ registry,
230
+ resolver,
231
+ store: memStore(),
232
+ });
233
+
234
+ // The principal IS authorized (same rule), but the model passed bad args:
235
+ // `amount` must be a positive integer. The schema validation gate refuses
236
+ // it WITHOUT running the dry-run.
237
+ await expect(
238
+ service.propose({
239
+ principal: limited,
240
+ toolName: "incident.escalate",
241
+ input: { amount: -5 },
242
+ transport: "chat",
243
+ rpcClient,
244
+ }),
245
+ ).rejects.toBeInstanceOf(ProposeApplyError);
246
+ expect(dryRan).toBe(false);
247
+ });
248
+ });
249
+
250
+ describe("HARDENING: scope-narrowing can never WIDEN the surfaced toolset", () => {
251
+ // The resolver predicate is the same one autoAuthMiddleware applies. A
252
+ // scope-narrowed principal carries a SMALLER accessRules set; intersection can
253
+ // only ever shrink the visible tools — never add one the principal lacks.
254
+ test("narrowing the principal's rules monotonically shrinks the visible tools", () => {
255
+ const registry = createAiToolRegistry();
256
+ registry.register(readTool("incident.list", "incident.incident.read", () => {}));
257
+ registry.register(readTool("hc.status", "healthcheck.config.read", () => {}));
258
+ registry.register(readTool("ai.secrets", "ai.tools.manage", () => {}));
259
+ const resolver = createAiToolResolver({ registry });
260
+
261
+ const wide: AuthUser = {
262
+ type: "user",
263
+ id: "u1",
264
+ accessRules: ["incident.incident.read", "healthcheck.config.read"],
265
+ };
266
+ const narrowed: AuthUser = {
267
+ type: "user",
268
+ id: "u1",
269
+ accessRules: ["incident.incident.read"], // a token granted only this
270
+ };
271
+
272
+ const wideNames = new Set(resolver.resolveTools(wide).map((t) => t.name));
273
+ const narrowNames = new Set(resolver.resolveTools(narrowed).map((t) => t.name));
274
+
275
+ // Narrowed is a strict subset — never a superset.
276
+ for (const name of narrowNames) expect(wideNames.has(name)).toBe(true);
277
+ expect(narrowNames.has("hc.status")).toBe(false);
278
+ expect(narrowNames.has("ai.secrets")).toBe(false);
279
+ // And the narrowing never invented a tool outside the wide set.
280
+ expect([...narrowNames].every((n) => wideNames.has(n))).toBe(true);
281
+ });
282
+ });
@@ -0,0 +1,303 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import { z } from "zod";
3
+ import { isSecretSchema, toJsonSchema } from "@checkstack/backend-api";
4
+ import {
5
+ AiChatIntegrationSchema,
6
+ AiConversationSchema,
7
+ AiMessageSchema,
8
+ AiProposalSchema,
9
+ AiToolDescriptorSchema,
10
+ } from "@checkstack/ai-common";
11
+ import { OpenAiCompatibleConnectionSchema } from "../openai-provider";
12
+ import { serializeTool, serializeTools } from "../serializer";
13
+ import type { RegisteredAiTool } from "../tool-registry";
14
+ import { createAiConversationStore } from "../chat/conversation-store";
15
+ import { REDACTED } from "../chat/scrub-content";
16
+
17
+ /**
18
+ * NO-SECRET-LEAK DTO hardening (matrix #16, plan §16, risk "Integration API key
19
+ * leaks").
20
+ *
21
+ * The single most security-sensitive guarantee of the AI platform: a provider
22
+ * credential (the OpenAI-compatible `apiKey`, or ANY `x-secret` field) must
23
+ * NEVER cross an AI-facing wire. This suite asserts that property structurally
24
+ * across EVERY AI-surface DTO the plan enumerates:
25
+ *
26
+ * - the MCP / OpenAI tool descriptor (and its JSON Schema),
27
+ * - the `listTools` introspection output,
28
+ * - the chat-integration picker DTO (`listChatIntegrations`),
29
+ * - chat message + conversation records,
30
+ * - the `ai_tool_calls` propose/apply proposal DTO,
31
+ * - and chat-message persistence (secrets masked before they hit Postgres).
32
+ *
33
+ * These are pure schema/serialization assertions (no DB, no DOM), so they run in
34
+ * the default root `bun test`.
35
+ */
36
+
37
+ /** A canary secret value. If it appears in ANY DTO, the test fails loudly. */
38
+ const SECRET = "sk-canary-DO-NOT-LEAK-0123456789abcdef";
39
+
40
+ /** Recursively assert the canary secret appears nowhere in a value. */
41
+ function assertNoSecret(value: unknown, where: string): void {
42
+ expect(JSON.stringify(value) ?? "", `${where} must not contain the secret`).not.toContain(
43
+ SECRET,
44
+ );
45
+ }
46
+
47
+ describe("no-secret-leak: the connection schema marks apiKey x-secret", () => {
48
+ test("apiKey is x-secret; baseUrl / defaultModel are not", () => {
49
+ const shape = OpenAiCompatibleConnectionSchema.shape;
50
+ expect(isSecretSchema(shape.apiKey)).toBe(true);
51
+ expect(isSecretSchema(shape.baseUrl)).toBe(false);
52
+ expect(isSecretSchema(shape.defaultModel)).toBe(false);
53
+ });
54
+
55
+ test("the emitted JSON Schema flags apiKey x-secret (so the platform redacts it)", () => {
56
+ const json = toJsonSchema(OpenAiCompatibleConnectionSchema) as {
57
+ properties?: Record<string, { "x-secret"?: boolean }>;
58
+ };
59
+ expect(json.properties?.apiKey?.["x-secret"]).toBe(true);
60
+ expect(json.properties?.baseUrl?.["x-secret"]).toBeUndefined();
61
+ });
62
+ });
63
+
64
+ describe("no-secret-leak: tool descriptors never carry a credential", () => {
65
+ // A tool whose INPUT schema happens to declare a secret field (worst case):
66
+ // the descriptor must still never serialize a secret VALUE, and it carries no
67
+ // executor closure that could close over the credential.
68
+ const credentialTool: RegisteredAiTool = {
69
+ name: "evil.echo",
70
+ description: "A tool whose schema references a secret-ish field.",
71
+ effect: "read",
72
+ input: z.object({ q: z.string() }),
73
+ requiredAccessRules: ["incident.incident.read"],
74
+ execute: () => Promise.resolve({ ok: true }),
75
+ };
76
+
77
+ test("serializeTool returns only JSON Schema + metadata (no executor, no secret value)", () => {
78
+ const descriptor = serializeTool({ tool: credentialTool });
79
+ // Structural: the descriptor parses as the wire DTO (which has no executor).
80
+ const parsed = AiToolDescriptorSchema.parse(descriptor);
81
+ expect(parsed.name).toBe("evil.echo");
82
+ expect("execute" in descriptor).toBe(false);
83
+ assertNoSecret(descriptor, "tool descriptor");
84
+ });
85
+
86
+ test("serializeTools (listTools / MCP tools-list) leaks no secret", () => {
87
+ const descriptors = serializeTools({ tools: [credentialTool] });
88
+ assertNoSecret(descriptors, "listTools output");
89
+ });
90
+ });
91
+
92
+ describe("no-secret-leak: chat-integration picker exposes model UX only", () => {
93
+ test("AiChatIntegration DTO has no apiKey field and round-trips without one", () => {
94
+ // The DTO shape itself cannot carry a credential: there is no key for it.
95
+ expect(Object.keys(AiChatIntegrationSchema.shape)).not.toContain("apiKey");
96
+ const dto = AiChatIntegrationSchema.parse({
97
+ connectionId: "ai.openai-compatible.c1",
98
+ name: "OpenAI",
99
+ defaultModel: "gpt-4o-mini",
100
+ availableModels: ["gpt-4o-mini", "gpt-4o"],
101
+ });
102
+ assertNoSecret(dto, "listChatIntegrations DTO");
103
+ });
104
+
105
+ test("an UNKNOWN extra key (a hand-crafted apiKey) is stripped by the schema", () => {
106
+ // Even if a buggy handler tried to spread the raw connection, parsing the
107
+ // DTO drops keys outside the schema — the credential cannot ride along.
108
+ const raw = {
109
+ connectionId: "c1",
110
+ name: "OpenAI",
111
+ defaultModel: "gpt-4o-mini",
112
+ apiKey: SECRET,
113
+ };
114
+ const dto = AiChatIntegrationSchema.parse(raw);
115
+ expect("apiKey" in dto).toBe(false);
116
+ assertNoSecret(dto, "stripped listChatIntegrations DTO");
117
+ });
118
+ });
119
+
120
+ describe("no-secret-leak: conversation + message + proposal DTOs", () => {
121
+ test("AiConversation DTO carries no secret", () => {
122
+ const dto = AiConversationSchema.parse({
123
+ id: "c1",
124
+ title: "Investigate prod",
125
+ integrationId: "ai.openai-compatible.c1",
126
+ model: "gpt-4o-mini",
127
+ permissionMode: "approve",
128
+ createdAt: "2026-06-02T00:00:00Z",
129
+ updatedAt: "2026-06-02T00:00:00Z",
130
+ });
131
+ expect(Object.keys(dto)).not.toContain("apiKey");
132
+ assertNoSecret(dto, "conversation DTO");
133
+ });
134
+
135
+ test("AiMessage DTO carries no secret", () => {
136
+ const dto = AiMessageSchema.parse({
137
+ id: "m1",
138
+ conversationId: "c1",
139
+ role: "assistant",
140
+ content: { text: "here is the plan" },
141
+ toolCalls: [{ toolName: "incident.list", args: {} }],
142
+ createdAt: "2026-06-02T00:00:00Z",
143
+ });
144
+ assertNoSecret(dto, "message DTO");
145
+ });
146
+
147
+ test("AiProposal DTO (the ai_tool_calls proposal row surface) carries no secret", () => {
148
+ const dto = AiProposalSchema.parse({
149
+ token: "propose:row-1.abcdef",
150
+ summary: "Create an automation",
151
+ payload: { trigger: "incident.opened", actions: [] },
152
+ toolCallId: "row-1",
153
+ expiresAt: "2026-06-02T00:10:00Z",
154
+ });
155
+ assertNoSecret(dto, "proposal DTO");
156
+ });
157
+ });
158
+
159
+ describe("no-secret-leak: the credential is structurally unreachable from AI DTOs", () => {
160
+ // The credential lives ONLY on the integration connection (an x-secret field
161
+ // the integration platform stores in the Secrets Vault and redacts). No AI
162
+ // DTO has a field that can carry it: the chat-integration picker exposes model
163
+ // UX metadata only, and the agent loop only ever forwards tool RESULTS (which
164
+ // the source read procedures already redact) into a message — never the
165
+ // connection. We assert the negative across the full DTO set with a single
166
+ // worst-case payload that embeds the canary in every plausible position.
167
+ test("a worst-case object embedding the secret never validates into any AI DTO with the secret intact", () => {
168
+ // Each schema either lacks a field to hold the secret (stripped on parse) or
169
+ // would carry it only inside a free-form content/payload bag. Parsing the
170
+ // strict-shaped DTOs drops the credential.
171
+ const conv = AiConversationSchema.parse({
172
+ id: "c1",
173
+ title: SECRET, // a title is user-controlled, but it is never the credential
174
+ integrationId: "ai.openai-compatible.c1",
175
+ model: "gpt-4o-mini",
176
+ permissionMode: "approve",
177
+ createdAt: "2026-06-02T00:00:00Z",
178
+ updatedAt: "2026-06-02T00:00:00Z",
179
+ });
180
+ // The conversation DTO has no credential field; a coincidental title is the
181
+ // user's own text, not the provider key. The integration picker (the only
182
+ // DTO derived FROM the connection) cannot carry it:
183
+ const picker = AiChatIntegrationSchema.parse({
184
+ connectionId: "ai.openai-compatible.c1",
185
+ name: "OpenAI",
186
+ defaultModel: "gpt-4o-mini",
187
+ apiKey: SECRET, // hand-crafted extra key
188
+ availableModels: ["gpt-4o-mini"],
189
+ });
190
+ expect("apiKey" in picker).toBe(false);
191
+ assertNoSecret(picker, "listChatIntegrations DTO (credential-derived surface)");
192
+ // Sanity: the conversation title carries the user's literal input verbatim,
193
+ // proving the schema does not silently scrub arbitrary strings — the leak
194
+ // guarantee is that the credential has NO field to ride on, not blanket
195
+ // string scrubbing.
196
+ expect(conv.title).toBe(SECRET);
197
+ });
198
+
199
+ test("the canary is detectable (the guard has teeth — assertions are not vacuous)", () => {
200
+ // If a DTO ever DID carry the credential, assertNoSecret would catch it.
201
+ expect(() => assertNoSecret({ leaked: SECRET }, "teeth-check")).toThrow();
202
+ });
203
+ });
204
+
205
+ describe("no-secret-leak: the message WRITE PATH enforces the guarantee (canary)", () => {
206
+ /**
207
+ * The free-form `ai_messages.content` bag could, in principle, hold a secret
208
+ * if a buggy/malicious tool result smuggled one in. The guarantee is no longer
209
+ * merely architectural: `appendMessage` scrubs credential-shaped keys/values
210
+ * BEFORE the row hits Postgres. We assert the canary is stripped on write by
211
+ * capturing what the store passes to `db.insert(...).values(...)`.
212
+ */
213
+ function captureStore() {
214
+ const captured: Array<Record<string, unknown>> = [];
215
+ const returning = mock(() => Promise.resolve([{ id: "m1" }]));
216
+ const values = mock((v: Record<string, unknown>) => {
217
+ captured.push(v);
218
+ return { returning };
219
+ });
220
+ const updateWhere = mock(() => Promise.resolve([]));
221
+ const set = mock(() => ({ where: updateWhere }));
222
+ const db = {
223
+ insert: mock(() => ({ values })),
224
+ update: mock(() => ({ set })),
225
+ };
226
+ return { store: createAiConversationStore({ db: db as never }), captured };
227
+ }
228
+
229
+ test("a credential injected into message content is STRIPPED on write (not persisted)", async () => {
230
+ const { store, captured } = captureStore();
231
+ await store.appendMessage({
232
+ conversationId: "c1",
233
+ role: "tool",
234
+ // Worst case: a tool result that leaked the integration apiKey + a raw key.
235
+ content: {
236
+ text: "tool ran",
237
+ result: { apiKey: SECRET, headers: { authorization: `Bearer ${SECRET}` } },
238
+ rawSecretValue: SECRET,
239
+ },
240
+ });
241
+ const inserted = captured[0];
242
+ // What would be written to Postgres carries NO secret anywhere.
243
+ assertNoSecret(inserted, "persisted message content");
244
+ const content = inserted?.content as Record<string, unknown>;
245
+ const result = content.result as Record<string, unknown>;
246
+ expect(result.apiKey).toBe(REDACTED);
247
+ expect(content.rawSecretValue).toBe(REDACTED);
248
+ // Non-secret structure survives.
249
+ expect(content.text).toBe("tool ran");
250
+ });
251
+
252
+ test("a credential inside the REPLAY modelMessages is STRIPPED on write", async () => {
253
+ const { store, captured } = captureStore();
254
+ await store.appendMessage({
255
+ conversationId: "c1",
256
+ role: "assistant",
257
+ content: { text: "done" },
258
+ modelMessages: [
259
+ {
260
+ role: "tool",
261
+ content: [
262
+ {
263
+ type: "tool-result",
264
+ toolCallId: "tc1",
265
+ toolName: "x.read",
266
+ output: { type: "json", value: { apiKey: SECRET } },
267
+ },
268
+ ],
269
+ },
270
+ ],
271
+ });
272
+ const inserted = captured[0];
273
+ assertNoSecret(inserted, "persisted replay modelMessages");
274
+ });
275
+
276
+ test("a credential in the denormalized toolCalls column is STRIPPED on write", async () => {
277
+ const { store, captured } = captureStore();
278
+ await store.appendMessage({
279
+ conversationId: "c1",
280
+ role: "assistant",
281
+ content: { text: "calling" },
282
+ // The render-time denormalized tool calls bag is scrubbed too, so a
283
+ // credential smuggled into a tool's args/result can never persist here.
284
+ toolCalls: [
285
+ {
286
+ toolName: "x.read",
287
+ args: { authorization: `Bearer ${SECRET}`, q: "open" },
288
+ result: { apiKey: SECRET },
289
+ },
290
+ ],
291
+ });
292
+ const inserted = captured[0];
293
+ assertNoSecret(inserted, "persisted toolCalls column");
294
+ const toolCalls = inserted?.toolCalls as Array<Record<string, unknown>>;
295
+ const args = toolCalls[0]?.args as Record<string, unknown>;
296
+ const result = toolCalls[0]?.result as Record<string, unknown>;
297
+ expect(args.authorization).toBe(REDACTED);
298
+ expect(result.apiKey).toBe(REDACTED);
299
+ // Non-secret args survive so the call still renders.
300
+ expect(args.q).toBe("open");
301
+ expect(toolCalls[0]?.toolName).toBe("x.read");
302
+ });
303
+ });
package/src/hooks.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+ import type { AiToolEffect } from "@checkstack/ai-common";
3
+
4
+ /**
5
+ * Status reached by a tool call when the {@link aiHooks.toolCalled} hook fires.
6
+ */
7
+ export type AiToolCalledStatus = "proposed" | "applied" | "executed" | "failed";
8
+
9
+ /**
10
+ * Payload for the {@link aiHooks.toolCalled} event.
11
+ *
12
+ * Deliberately carries NO arguments and NO result body — only metadata. The
13
+ * audit row (`ai_tool_calls`) stores a SHA-256 `argsHash`, never the raw args,
14
+ * because they may carry PII or secrets (§10). Subscribers (notifications,
15
+ * anomaly-context, etc.) react to the fact of a call, not its contents.
16
+ */
17
+ export interface AiToolCalledPayload {
18
+ principalKind: "user" | "application";
19
+ principalId: string;
20
+ transport: "chat" | "mcp" | "automation";
21
+ toolName: string;
22
+ effect: AiToolEffect;
23
+ status: AiToolCalledStatus;
24
+ }
25
+
26
+ /**
27
+ * AI platform hooks. Emitted on the shared event bus so subscribers on every
28
+ * pod receive them (cluster-wide), independent of which pod ran the tool.
29
+ */
30
+ export const aiHooks = {
31
+ /** Emitted for every tool invocation (propose / apply / read). */
32
+ toolCalled: createHook<AiToolCalledPayload>("ai.toolCalled"),
33
+ };