feat(giellie): tool policy (deny/read/write/confirm classification)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d611efe77a
commit
5b0a572e5c
2 changed files with 251 additions and 0 deletions
112
src/lib/server/giellie-tool-policy.test.ts
Normal file
112
src/lib/server/giellie-tool-policy.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
139
src/lib/server/giellie-tool-policy.ts
Normal file
139
src/lib/server/giellie-tool-policy.ts
Normal file
|
|
@ -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<string> = 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<string> = 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<string> = 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.]`
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue