@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,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP Streamable-HTTP wire conformance (matrix #9).
|
|
5
|
+
*
|
|
6
|
+
* Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it. Drives
|
|
7
|
+
* a REAL running backend's MCP endpoint over HTTP to validate the
|
|
8
|
+
* initialize -> tools/list -> tools/call round-trip against a live, OAuth-token
|
|
9
|
+
* authenticated session. Configure:
|
|
10
|
+
* - `CHECKSTACK_IT_MCP_URL` (defaults to http://localhost:3000/api/ai/mcp)
|
|
11
|
+
* - `CHECKSTACK_IT_MCP_TOKEN` (a minted opaque OAuth access token, NARROWED to a
|
|
12
|
+
* read scope that does NOT include `ai.tools.manage` — so `ai.secrets` is out
|
|
13
|
+
* of scope and a mutating tool like `incident.close` is not invocable)
|
|
14
|
+
*
|
|
15
|
+
* This is the only assertion that exercises the full opaque-token introspection
|
|
16
|
+
* + live-router re-entry path end-to-end, so it lives as an integration test.
|
|
17
|
+
* Phase 5 adds the negative wire assertions: an out-of-scope tool is refused
|
|
18
|
+
* (and the router is never re-entered), and a mutating tool is refused by the
|
|
19
|
+
* structural effect-gate (propose/apply only).
|
|
20
|
+
*/
|
|
21
|
+
const MCP_URL =
|
|
22
|
+
process.env.CHECKSTACK_IT_MCP_URL ?? "http://localhost:3000/api/ai/mcp";
|
|
23
|
+
const MCP_TOKEN = process.env.CHECKSTACK_IT_MCP_TOKEN ?? "";
|
|
24
|
+
/** A tool the IT token's scope does NOT grant (admin-only). */
|
|
25
|
+
const OUT_OF_SCOPE_TOOL =
|
|
26
|
+
process.env.CHECKSTACK_IT_MCP_OUT_OF_SCOPE_TOOL ?? "ai.secrets";
|
|
27
|
+
/** A mutating tool that must be refused via the effect-gate (never run). */
|
|
28
|
+
const MUTATING_TOOL =
|
|
29
|
+
process.env.CHECKSTACK_IT_MCP_MUTATING_TOOL ?? "incident.close";
|
|
30
|
+
|
|
31
|
+
async function rpc(body: unknown): Promise<{ status: number; json: unknown }> {
|
|
32
|
+
const res = await fetch(MCP_URL, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"content-type": "application/json",
|
|
36
|
+
authorization: `Bearer ${MCP_TOKEN}`,
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
return { status: res.status, json: text ? JSON.parse(text) : null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Requires a REAL running backend (CHECKSTACK_IT_MCP_URL) reachable with a
|
|
45
|
+
// minted, scope-narrowed OAuth token (CHECKSTACK_IT_MCP_TOKEN). The plain
|
|
46
|
+
// `CHECKSTACK_IT` PG/Redis integration lane does not stand up that backend, so
|
|
47
|
+
// gate on the token too - otherwise these tests fail with "Unable to connect"
|
|
48
|
+
// instead of skipping where the prerequisite isn't provided.
|
|
49
|
+
describe.skipIf(!process.env.CHECKSTACK_IT || !process.env.CHECKSTACK_IT_MCP_TOKEN)(
|
|
50
|
+
"MCP Streamable-HTTP conformance",
|
|
51
|
+
() => {
|
|
52
|
+
test("initialize advertises a protocol version and a session id", async () => {
|
|
53
|
+
const res = await fetch(MCP_URL, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
authorization: `Bearer ${MCP_TOKEN}`,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }),
|
|
60
|
+
});
|
|
61
|
+
const json = (await res.json()) as {
|
|
62
|
+
result?: { protocolVersion?: string };
|
|
63
|
+
};
|
|
64
|
+
expect(json.result?.protocolVersion).toBeTruthy();
|
|
65
|
+
expect(res.headers.get("mcp-session-id")).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("tools/list returns the read-only tool surface", async () => {
|
|
69
|
+
const { json } = (await rpc({
|
|
70
|
+
jsonrpc: "2.0",
|
|
71
|
+
id: 2,
|
|
72
|
+
method: "tools/list",
|
|
73
|
+
})) as { json: { result?: { tools?: Array<{ name: string }> } } };
|
|
74
|
+
const names = (json.result?.tools ?? []).map((t) => t.name);
|
|
75
|
+
// The principal must at least be able to list incidents in the IT env.
|
|
76
|
+
expect(names).toContain("incident.list");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("tools/call returns a non-error content block", async () => {
|
|
80
|
+
const { json } = (await rpc({
|
|
81
|
+
jsonrpc: "2.0",
|
|
82
|
+
id: 3,
|
|
83
|
+
method: "tools/call",
|
|
84
|
+
params: { name: "incident.list", arguments: {} },
|
|
85
|
+
})) as { json: { result?: { isError?: boolean } } };
|
|
86
|
+
expect(json.result?.isError).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("tools/list never lists an out-of-scope tool", async () => {
|
|
90
|
+
const { json } = (await rpc({
|
|
91
|
+
jsonrpc: "2.0",
|
|
92
|
+
id: 4,
|
|
93
|
+
method: "tools/list",
|
|
94
|
+
})) as { json: { result?: { tools?: Array<{ name: string }> } } };
|
|
95
|
+
const names = (json.result?.tools ?? []).map((t) => t.name);
|
|
96
|
+
// The narrowed token does not grant the admin rule, so the tool is hidden.
|
|
97
|
+
expect(names).not.toContain(OUT_OF_SCOPE_TOOL);
|
|
98
|
+
// A mutating tool is never on the read-only list either.
|
|
99
|
+
expect(names).not.toContain(MUTATING_TOOL);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("tools/call for an out-of-scope tool is REFUSED 403 (not merely hidden)", async () => {
|
|
103
|
+
const { status, json } = (await rpc({
|
|
104
|
+
jsonrpc: "2.0",
|
|
105
|
+
id: 5,
|
|
106
|
+
method: "tools/call",
|
|
107
|
+
params: { name: OUT_OF_SCOPE_TOOL, arguments: {} },
|
|
108
|
+
})) as { status: number; json: { error?: { message?: string } } };
|
|
109
|
+
// Handler-side authz holds when the model misbehaves: the resolver gate
|
|
110
|
+
// refuses BEFORE the live router is re-entered.
|
|
111
|
+
expect(status).toBe(403);
|
|
112
|
+
expect(json.error?.message).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("tools/call for a mutating tool is refused by the structural effect-gate", async () => {
|
|
116
|
+
const { status, json } = (await rpc({
|
|
117
|
+
jsonrpc: "2.0",
|
|
118
|
+
id: 6,
|
|
119
|
+
method: "tools/call",
|
|
120
|
+
params: { name: MUTATING_TOOL, arguments: {} },
|
|
121
|
+
})) as { status: number; json: { error?: { message?: string } } };
|
|
122
|
+
// A bare tools/call may only run a read tool; mutating tools MUST go
|
|
123
|
+
// through propose/apply. The handler refuses with 403 and never executes.
|
|
124
|
+
expect(status).toBe(403);
|
|
125
|
+
expect(json.error?.message).toContain("propose/apply");
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
);
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { AuthService, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import { createMcpRequestHandler } from "./server";
|
|
5
|
+
import type { McpExecutableTool } from "./server";
|
|
6
|
+
import { createAiToolResolver } from "../resolver";
|
|
7
|
+
import { createAiToolRegistry } from "../tool-registry";
|
|
8
|
+
import type { McpToolInvoker } from "./tool-invoker";
|
|
9
|
+
import { createMcpConnectionRegistry } from "./connection-registry";
|
|
10
|
+
import type { RegisteredAiTool } from "../tool-registry";
|
|
11
|
+
|
|
12
|
+
function readTool(name: string, rule: string): RegisteredAiTool {
|
|
13
|
+
return {
|
|
14
|
+
name,
|
|
15
|
+
description: `${name} (read-only)`,
|
|
16
|
+
effect: "read",
|
|
17
|
+
input: z.object({}),
|
|
18
|
+
requiredAccessRules: [rule],
|
|
19
|
+
execute: () => Promise.resolve({}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mutateTool(name: string, rule: string): RegisteredAiTool {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
description: `${name} (mutating)`,
|
|
27
|
+
effect: "mutate",
|
|
28
|
+
input: z.object({}),
|
|
29
|
+
requiredAccessRules: [rule],
|
|
30
|
+
execute: () => Promise.resolve({}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildHandler({
|
|
35
|
+
principal,
|
|
36
|
+
invoke,
|
|
37
|
+
enforceBudget,
|
|
38
|
+
recordExecuted,
|
|
39
|
+
}: {
|
|
40
|
+
principal: AuthUser | undefined;
|
|
41
|
+
invoke?: McpToolInvoker["invoke"];
|
|
42
|
+
enforceBudget?: (p: { kind: string; id: string }) => Promise<void>;
|
|
43
|
+
recordExecuted?: (args: {
|
|
44
|
+
principal: { kind: string; id: string };
|
|
45
|
+
toolName: string;
|
|
46
|
+
argsHash: string;
|
|
47
|
+
}) => Promise<void>;
|
|
48
|
+
}) {
|
|
49
|
+
const registry = createAiToolRegistry();
|
|
50
|
+
const incidentTool = readTool("incident.list", "incident.incident.read");
|
|
51
|
+
const adminTool = readTool("ai.secrets", "ai.tools.manage");
|
|
52
|
+
// A mutating tool the limited principal IS allowed for (same access rule as
|
|
53
|
+
// incident.list). The ONLY thing that may refuse a bare tools/call for it is
|
|
54
|
+
// the structural effect-gate, not the resolver.
|
|
55
|
+
const mutating = mutateTool("incident.close", "incident.incident.read");
|
|
56
|
+
registry.register(incidentTool);
|
|
57
|
+
registry.register(adminTool);
|
|
58
|
+
registry.register(mutating);
|
|
59
|
+
const resolver = createAiToolResolver({ registry });
|
|
60
|
+
|
|
61
|
+
const tools: McpExecutableTool[] = [
|
|
62
|
+
{ tool: incidentTool, pluginId: "incident", procedureKey: "listIncidents" },
|
|
63
|
+
{ tool: adminTool, pluginId: "ai", procedureKey: "secrets" },
|
|
64
|
+
{ tool: mutating, pluginId: "incident", procedureKey: "closeIncident" },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const invoker: McpToolInvoker = {
|
|
68
|
+
invoke: invoke ?? (() => Promise.resolve({ ok: true })),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const auth: AuthService = {
|
|
72
|
+
authenticate: () => Promise.resolve(principal),
|
|
73
|
+
getCredentials: () => Promise.resolve({ headers: {} }),
|
|
74
|
+
getAnonymousAccessRules: () => Promise.resolve([]),
|
|
75
|
+
checkResourceTeamAccess: () => Promise.resolve(false),
|
|
76
|
+
} as unknown as AuthService;
|
|
77
|
+
|
|
78
|
+
return createMcpRequestHandler({
|
|
79
|
+
tools,
|
|
80
|
+
resolver,
|
|
81
|
+
invoker,
|
|
82
|
+
auth,
|
|
83
|
+
connections: createMcpConnectionRegistry(),
|
|
84
|
+
enforceBudget,
|
|
85
|
+
recordExecuted,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mcpPost(body: unknown, token = "opaque-token"): Request {
|
|
90
|
+
return new Request("http://localhost/api/ai/mcp", {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
authorization: `Bearer ${token}`,
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const limitedPrincipal: AuthUser = {
|
|
101
|
+
type: "user",
|
|
102
|
+
id: "u1",
|
|
103
|
+
accessRules: ["incident.incident.read"],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
107
|
+
test("initialize returns protocol version + a session id header", async () => {
|
|
108
|
+
const handler = buildHandler({ principal: limitedPrincipal });
|
|
109
|
+
const res = await handler(
|
|
110
|
+
mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize" }),
|
|
111
|
+
);
|
|
112
|
+
const json = await res.json();
|
|
113
|
+
expect(json.result.protocolVersion).toBeDefined();
|
|
114
|
+
expect(res.headers.get("mcp-session-id")).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("tools/list only surfaces tools the principal may call", async () => {
|
|
118
|
+
const handler = buildHandler({ principal: limitedPrincipal });
|
|
119
|
+
const res = await handler(
|
|
120
|
+
mcpPost({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
|
|
121
|
+
);
|
|
122
|
+
const json = await res.json();
|
|
123
|
+
const names = json.result.tools.map((t: { name: string }) => t.name);
|
|
124
|
+
expect(names).toEqual(["incident.list"]);
|
|
125
|
+
expect(names).not.toContain("ai.secrets");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("tools/list returns 401 for an unauthenticated caller", async () => {
|
|
129
|
+
const handler = buildHandler({ principal: undefined });
|
|
130
|
+
const res = await handler(
|
|
131
|
+
mcpPost({ jsonrpc: "2.0", id: 3, method: "tools/list" }),
|
|
132
|
+
);
|
|
133
|
+
expect(res.status).toBe(401);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Matrix #8: an MCP call for a tool OUTSIDE the token's scopes is rejected,
|
|
137
|
+
// not just hidden — handler-side authz holds when the model misbehaves.
|
|
138
|
+
test("tools/call for an out-of-scope tool is REFUSED (not merely hidden)", async () => {
|
|
139
|
+
let invoked = false;
|
|
140
|
+
const handler = buildHandler({
|
|
141
|
+
principal: limitedPrincipal,
|
|
142
|
+
invoke: () => {
|
|
143
|
+
invoked = true;
|
|
144
|
+
return Promise.resolve({});
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
// The model names a tool the principal lacks the rule for.
|
|
148
|
+
const res = await handler(
|
|
149
|
+
mcpPost({
|
|
150
|
+
jsonrpc: "2.0",
|
|
151
|
+
id: 4,
|
|
152
|
+
method: "tools/call",
|
|
153
|
+
params: { name: "ai.secrets", arguments: {} },
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
expect(res.status).toBe(403);
|
|
157
|
+
// Crucially: the live router was NEVER re-entered for a forbidden tool.
|
|
158
|
+
expect(invoked).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("tools/call for an allowed tool re-enters the router with the bearer token", async () => {
|
|
162
|
+
let seenToken: string | undefined;
|
|
163
|
+
let seenRoute: string | undefined;
|
|
164
|
+
const handler = buildHandler({
|
|
165
|
+
principal: limitedPrincipal,
|
|
166
|
+
invoke: ({ bearerToken, pluginId, procedureKey, input }) => {
|
|
167
|
+
seenToken = bearerToken;
|
|
168
|
+
seenRoute = `${pluginId}.${procedureKey}`;
|
|
169
|
+
return Promise.resolve({ echoed: input });
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const res = await handler(
|
|
173
|
+
mcpPost(
|
|
174
|
+
{
|
|
175
|
+
jsonrpc: "2.0",
|
|
176
|
+
id: 5,
|
|
177
|
+
method: "tools/call",
|
|
178
|
+
params: { name: "incident.list", arguments: { status: "open" } },
|
|
179
|
+
},
|
|
180
|
+
"tok-123",
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
const json = await res.json();
|
|
184
|
+
expect(json.result.isError).toBe(false);
|
|
185
|
+
// The caller's own token is forwarded so the router re-checks authz live.
|
|
186
|
+
expect(seenToken).toBe("tok-123");
|
|
187
|
+
expect(seenRoute).toBe("incident.listIncidents");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// P2 review fix: the read-only-over-MCP guarantee is STRUCTURAL. Even when
|
|
191
|
+
// the principal IS authorized for a mutating tool, a bare tools/call must be
|
|
192
|
+
// refused with 403 and the live router must never be re-entered — mutating
|
|
193
|
+
// tools go through propose/apply, never a direct invocation.
|
|
194
|
+
test("tools/call for a mutating tool is structurally REFUSED even when authorized", async () => {
|
|
195
|
+
let invoked = false;
|
|
196
|
+
const handler = buildHandler({
|
|
197
|
+
principal: limitedPrincipal,
|
|
198
|
+
invoke: () => {
|
|
199
|
+
invoked = true;
|
|
200
|
+
return Promise.resolve({});
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const res = await handler(
|
|
204
|
+
mcpPost({
|
|
205
|
+
jsonrpc: "2.0",
|
|
206
|
+
id: 7,
|
|
207
|
+
method: "tools/call",
|
|
208
|
+
params: { name: "incident.close", arguments: {} },
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
expect(res.status).toBe(403);
|
|
212
|
+
const json = await res.json();
|
|
213
|
+
expect(json.error.message).toContain("propose/apply");
|
|
214
|
+
expect(invoked).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("tools/list excludes mutating tools (only the read-only surface)", async () => {
|
|
218
|
+
const handler = buildHandler({ principal: limitedPrincipal });
|
|
219
|
+
const res = await handler(
|
|
220
|
+
mcpPost({ jsonrpc: "2.0", id: 8, method: "tools/list" }),
|
|
221
|
+
);
|
|
222
|
+
const json = await res.json();
|
|
223
|
+
const names = json.result.tools.map((t: { name: string }) => t.name);
|
|
224
|
+
expect(names).toContain("incident.list");
|
|
225
|
+
expect(names).not.toContain("incident.close");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// §14.5: per-principal tool budget enforced on tools/call (shared-Postgres).
|
|
229
|
+
test("tools/call over the per-principal budget returns 429 and never invokes", async () => {
|
|
230
|
+
let invoked = false;
|
|
231
|
+
const handler = buildHandler({
|
|
232
|
+
principal: limitedPrincipal,
|
|
233
|
+
invoke: () => {
|
|
234
|
+
invoked = true;
|
|
235
|
+
return Promise.resolve({});
|
|
236
|
+
},
|
|
237
|
+
enforceBudget: () => Promise.reject(new Error("budget exceeded")),
|
|
238
|
+
});
|
|
239
|
+
const res = await handler(
|
|
240
|
+
mcpPost({
|
|
241
|
+
jsonrpc: "2.0",
|
|
242
|
+
id: 9,
|
|
243
|
+
method: "tools/call",
|
|
244
|
+
params: { name: "incident.list", arguments: {} },
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
expect(res.status).toBe(429);
|
|
248
|
+
expect(invoked).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a within-budget tools/call records an audit row (matrix #13)", async () => {
|
|
252
|
+
const recorded: Array<{ toolName: string; argsHash: string }> = [];
|
|
253
|
+
const handler = buildHandler({
|
|
254
|
+
principal: limitedPrincipal,
|
|
255
|
+
invoke: () => Promise.resolve({ ok: true }),
|
|
256
|
+
enforceBudget: () => Promise.resolve(),
|
|
257
|
+
recordExecuted: ({ toolName, argsHash }) => {
|
|
258
|
+
recorded.push({ toolName, argsHash });
|
|
259
|
+
return Promise.resolve();
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const res = await handler(
|
|
263
|
+
mcpPost({
|
|
264
|
+
jsonrpc: "2.0",
|
|
265
|
+
id: 10,
|
|
266
|
+
method: "tools/call",
|
|
267
|
+
params: { name: "incident.list", arguments: { status: "open" } },
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
expect(res.status).toBe(200);
|
|
271
|
+
expect(recorded).toHaveLength(1);
|
|
272
|
+
expect(recorded[0]?.toolName).toBe("incident.list");
|
|
273
|
+
// The args hash is a SHA-256 hex digest, never the raw args.
|
|
274
|
+
expect(recorded[0]?.argsHash).toMatch(/^[0-9a-f]{64}$/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("unknown method returns a JSON-RPC method-not-found error", async () => {
|
|
278
|
+
const handler = buildHandler({ principal: limitedPrincipal });
|
|
279
|
+
const res = await handler(
|
|
280
|
+
mcpPost({ jsonrpc: "2.0", id: 6, method: "bogus/method" }),
|
|
281
|
+
);
|
|
282
|
+
const json = await res.json();
|
|
283
|
+
expect(json.error.code).toBe(-32601);
|
|
284
|
+
});
|
|
285
|
+
});
|