SysDeck/klanker-gate/tests/integration/tool_security_test.ts

131 lines
4.1 KiB
TypeScript
Executable File

// Side-effect tool security through the real gateway route: unconfirmed
// side-effect tools are refused; the explicit confirmation header allows them.
import { assertEquals } from "@std/assert";
import { createHandler } from "../../apps/gateway/main.ts";
import { type AppContext, VERSION } from "../../apps/gateway/context.ts";
import type { ToolExecutor } from "../../packages/core/src/mod.ts";
import { ProviderManager } from "../../packages/providers/src/mod.ts";
import { Metrics } from "../../packages/telemetry/src/metrics.ts";
import { LogBus } from "../../packages/telemetry/src/logbus.ts";
import { VirtualKeyManager } from "../../packages/governance/src/virtual_keys.ts";
import { MCPRegistry } from "../../packages/mcp/src/registry.ts";
import { PluginManager } from "../../packages/plugins/src/lifecycle.ts";
import {
jsonResponse,
MockProvider,
openAIChatBody,
} from "../../packages/testing/src/mod.ts";
class DeleterExecutor implements ToolExecutor {
executed = 0;
has(name: string): boolean {
return name === "delete_everything";
}
isSideEffect(_name: string): boolean {
return true;
}
execute(_name: string, _args: unknown): Promise<string> {
this.executed++;
return Promise.resolve("deleted");
}
}
function toolCallResponse() {
return openAIChatBody("", {
choices: [{
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [{
id: "call_1",
type: "function",
function: { name: "delete_everything", arguments: "{}" },
}],
},
finish_reason: "tool_calls",
}],
});
}
function makeContext(mockUrl: string, executor: ToolExecutor): AppContext {
return {
providers: new ProviderManager([{
id: "openai",
type: "openai",
apiKey: "k",
baseUrl: mockUrl,
enabled: true,
models: ["m1"],
priority: 0,
retry: { maxRetries: 0 },
}], "openai"),
metrics: new Metrics(),
logBus: new LogBus(),
virtualKeys: new VirtualKeyManager(),
mcp: new MCPRegistry(),
plugins: new PluginManager(),
toolExecutor: executor,
version: VERSION,
};
}
function chatWithTools(confirm: boolean): Request {
return new Request("http://gateway.test/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(confirm ? { "x-frosty-confirm-side-effects": "true" } : {}),
},
body: JSON.stringify({
model: "m1",
messages: [{ role: "user", content: "clean up" }],
tools: [{
type: "function",
function: { name: "delete_everything", parameters: {} },
}],
}),
});
}
Deno.test("gateway refuses unconfirmed side-effect tools with 403", async () => {
const mock = new MockProvider(() => jsonResponse(toolCallResponse()));
const executor = new DeleterExecutor();
try {
const handler = createHandler(makeContext(mock.url, executor));
const res = await handler(chatWithTools(false));
assertEquals(res.status, 403);
const body = await res.json();
assertEquals(body.error.type, "side_effect_denied");
assertEquals(executor.executed, 0); // nothing ran
} finally {
await mock.close();
}
});
Deno.test("explicit confirmation header allows side-effect execution", async () => {
const executor = new DeleterExecutor();
const mock = new MockProvider((_call, index) =>
jsonResponse(
index === 0 ? toolCallResponse() : openAIChatBody("all clean"),
)
);
try {
const handler = createHandler(makeContext(mock.url, executor));
const res = await handler(chatWithTools(true));
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.choices[0].message.content, "all clean");
assertEquals(executor.executed, 1);
// the tool result went back upstream on the second call
const second = mock.calls[1].body as {
messages: Array<{ role: string; content?: string }>;
};
assertEquals(second.messages.at(-1)?.role, "tool");
assertEquals(second.messages.at(-1)?.content, "deleted");
} finally {
await mock.close();
}
});