diff --git a/src/lib/server/giellie-tool-policy.test.ts b/src/lib/server/giellie-tool-policy.test.ts new file mode 100644 index 0000000..e1bd1f0 --- /dev/null +++ b/src/lib/server/giellie-tool-policy.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { + classifyTool, + toModelToolName, + fromModelToolName, + toAnthropicTools, + truncateForModel, +} from "./giellie-tool-policy"; + +describe("classifyTool", () => { + it.each([ + "companies.delete", + "customers.delete", + "suppliers.delete", + "ai_settings.get", + "ai_settings.update", + "files.fetch", + "quotes.get_pdf", + ])("denies %s", (n) => expect(classifyTool(n)).toBe("denied")); + + it("denies any future *.delete tool", () => { + expect(classifyTool("orders.delete")).toBe("denied"); + expect(classifyTool("widgets.delete")).toBe("denied"); + }); + + it.each([ + "orders.list", + "orders.get", + "orders.find_by_order_number", + "quotes.lookup", + "quote_items.list", + "stock_items.list", + "suppliers.list", + "customers.get", + "audit_log.get", + "dashboard.daily_summary", + "purchase_orders.diff", + "purchase_orders.scan", + "delivery_notes.list", + ])("treats %s as read", (n) => expect(classifyTool(n)).toBe("read")); + + it.each([ + "quotes.send_email", + "quotes.retry_email", + "purchase_orders.send_email", + "purchase_orders.retry_email", + "stock_purchases.send", + "stock_purchases.send_email", + "stock_purchases.retry_email", + ])("requires confirmation for %s", (n) => expect(classifyTool(n)).toBe("confirm")); + + it("treats ordinary mutations as write", () => { + expect(classifyTool("orders.update")).toBe("write"); + expect(classifyTool("orders.cancel")).toBe("write"); + expect(classifyTool("quotes.create")).toBe("write"); + expect(classifyTool("price_list.add_items")).toBe("write"); + }); + + it("treats counter-reserving *.next_number as write", () => { + expect(classifyTool("quotes.next_number")).toBe("write"); + expect(classifyTool("stock_purchases.next_number")).toBe("write"); + }); + + it("default-denies (write) unknown tools", () => { + expect(classifyTool("brand_new.tool")).toBe("write"); + }); +}); + +describe("tool name mapping", () => { + it("round-trips dotted names", () => { + expect(toModelToolName("stock_purchases.match_and_receive_from_invoice")).toBe( + "stock_purchases__match_and_receive_from_invoice", + ); + expect(fromModelToolName("orders__list")).toBe("orders.list"); + expect(fromModelToolName(toModelToolName("quote_items.get"))).toBe("quote_items.get"); + }); + it("produces names valid for the Anthropic tools API", () => { + expect(toModelToolName("stock_purchases.match_and_receive_from_invoice")).toMatch( + /^[a-zA-Z0-9_-]{1,64}$/, + ); + }); +}); + +describe("toAnthropicTools", () => { + it("drops denied tools and maps the schema", () => { + const out = toAnthropicTools([ + { name: "orders.get", description: "Get", inputSchema: { type: "object", properties: {} } }, + { name: "customers.delete", description: "Delete", inputSchema: { type: "object" } }, + { name: "orders.list" }, + ]); + expect(out.map((t) => t.name)).toEqual(["orders__get", "orders__list"]); + expect(out[0]).toEqual({ + name: "orders__get", + description: "Get", + input_schema: { type: "object", properties: {} }, + }); + expect(out[1].input_schema).toEqual({ type: "object", properties: {} }); + expect(out[1].description).toBe(""); + }); +}); + +describe("truncateForModel", () => { + it("passes short text through", () => { + expect(truncateForModel("abc", 10)).toBe("abc"); + }); + it("truncates loudly", () => { + const out = truncateForModel("x".repeat(100), 10); + expect(out.startsWith("xxxxxxxxxx")).toBe(true); + expect(out).toContain("TRUNCATED"); + expect(out).toContain("100"); + }); +}); diff --git a/src/lib/server/giellie-tool-policy.ts b/src/lib/server/giellie-tool-policy.ts new file mode 100644 index 0000000..c8ce773 --- /dev/null +++ b/src/lib/server/giellie-tool-policy.ts @@ -0,0 +1,139 @@ +/** + * Giellie tool policy — which MCP tools the agent may use and how. + * + * denied never offered, never executable + * read runs immediately + * write pauses for approval (auto-approved when the chat is "trusted") + * confirm pauses for approval ALWAYS (customer/supplier sends) + * + * Default-deny: a tool is "read" only if listed in READ_TOOLS. Anything + * unknown — including tools added to the MCP later — is a write and needs + * approval. Add new read-only tools here deliberately. + */ + +export type ToolClass = "denied" | "read" | "write" | "confirm"; + +/** Explicitly removed from the catalogue. `*.delete` is also caught by suffix. */ +export const DENIED_TOOLS: ReadonlySet = new Set([ + "companies.delete", + "customers.delete", + "suppliers.delete", + // Would expose / rewrite the LLM API key. + "ai_settings.get", + "ai_settings.update", + // Return whole files as base64 — would blow the context window. + "files.fetch", + "quotes.get_pdf", +]); + +/** Anything that emails a customer or supplier. Ignores the trust toggle. */ +export const CONFIRM_TOOLS: ReadonlySet = new Set([ + "quotes.send_email", + "quotes.retry_email", + "purchase_orders.send_email", + "purchase_orders.retry_email", + "stock_purchases.send", + "stock_purchases.send_email", + "stock_purchases.retry_email", +]); + +/** Verified non-mutating tools (checked against route.ts handlers 2026-09-30). */ +export const READ_TOOLS: ReadonlySet = new Set([ + "orders.list", + "orders.get", + "orders.stats", + "orders.daily_summary", + "orders.sprint_counts", + "orders.find_duplicates", + "orders.find_by_order_number", + "orders.find_by_email_message_id", + "orders.review", + "orders.matched_attachments", + "orders.list_attachments", + "audit_log.get", + "collection_pool.list", + "collection_confirm.list", + "order_items.list", + "order_items.get", + "quotes.list", + "quotes.get", + "quotes.lookup", + "quotes.find_by_message_id", + "quotes.list_attachments", + "quote_items.list", + "quote_items.get", + "purchase_orders.list", + "purchase_orders.get", + "purchase_orders.stale", + "purchase_orders.scan", + "purchase_orders.diff", + "stock_items.list", + "stock_items.get", + "stock_purchases.list", + "stock_purchases.get", + "stock_purchase_items.list", + "sprint.list", + "sprint.attachment_counts", + "sprint.item_attachments", + "buyer_tasks.list", + "companies.list", + "companies.get", + "customers.list", + "customers.get", + "suppliers.list", + "suppliers.get", + "tools.discovery", + "users.lookup", + "dashboard.daily_summary", + "drivers.list_stops", + "stock_quick_draft.search", + "stock_quick_draft.suppliers", + "delivery_notes.list", +]); + +export function classifyTool(name: string): ToolClass { + if (DENIED_TOOLS.has(name) || name.endsWith(".delete")) return "denied"; + if (CONFIRM_TOOLS.has(name)) return "confirm"; + if (READ_TOOLS.has(name)) return "read"; + return "write"; +} + +/** Anthropic tool names allow `[a-zA-Z0-9_-]` only; MCP names contain dots. */ +export function toModelToolName(name: string): string { + return name.replace(/\./g, "__"); +} +export function fromModelToolName(name: string): string { + return name.replace(/__/g, "."); +} + +export interface McpTool { + name: string; + description?: string; + inputSchema?: unknown; +} +export interface ModelTool { + name: string; + description: string; + input_schema: unknown; +} + +export function toAnthropicTools(tools: McpTool[]): ModelTool[] { + return tools + .filter((t) => classifyTool(t.name) !== "denied") + .map((t) => ({ + name: toModelToolName(t.name), + description: t.description ?? "", + input_schema: t.inputSchema ?? { type: "object", properties: {} }, + })); +} + +export const MODEL_RESULT_MAX_CHARS = 60_000; + +export function truncateForModel(text: string, max: number = MODEL_RESULT_MAX_CHARS): string { + if (text.length <= max) return text; + return ( + text.slice(0, max) + + `\n\n[TRUNCATED: result was ${text.length} characters, showing the first ${max}. ` + + `Narrow the query (filters, smaller perPage, date range) to see the rest.]` + ); +}