feat(giellie): tool policy (deny/read/write/confirm classification)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-30 23:36:39 +02:00
parent d611efe77a
commit 5b0a572e5c
2 changed files with 251 additions and 0 deletions

View 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");
});
});

View 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.]`
);
}