diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 97d6a36bf..fbc443722 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -247,6 +247,15 @@ export const documentDeleteTool = ( }), execute: async ({ documentId, containerTag }) => { try { + // A model-supplied tag may only select one of the tags the developer + // configured. Accepting an arbitrary value would let the model delete + // documents outside the configured scope. + if (containerTag && !containerTags.includes(containerTag)) { + throw new Error( + `Container tag "${containerTag}" is outside the configured scope.`, + ) + } + const scopeTags: [string, ...string[]] = containerTag ? [containerTag] : containerTags diff --git a/packages/tools/src/document-delete-scope.test.ts b/packages/tools/src/document-delete-scope.test.ts new file mode 100644 index 000000000..25f10932f --- /dev/null +++ b/packages/tools/src/document-delete-scope.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Mock the Supermemory SDK (same pattern as tool-operations.test.ts) so tool +// executions can be verified deterministically without network access. +const documentsDeleteBulk = vi.fn() +const documentsGet = vi.fn() +const documentsList = vi.fn() + +vi.mock("supermemory", () => { + return { + default: class MockSupermemory { + documents = { + deleteBulk: documentsDeleteBulk, + get: documentsGet, + list: documentsList, + } + }, + } +}) + +import * as aiSdk from "./ai-sdk" +import * as openAi from "./openai/tools" + +const API_KEY = "sm_test_key" + +type ToolExecutionResult = { success: boolean; error?: string } +type ToolWithExecute = { + execute: (args: Record) => Promise +} + +function executeTool( + tool: unknown, + args: Record, +): Promise { + return (tool as ToolWithExecute).execute(args) +} + +beforeEach(() => { + documentsDeleteBulk.mockReset().mockResolvedValue({ + success: true, + deletedCount: 1, + errors: [], + }) + documentsGet.mockReset() + documentsList.mockReset().mockResolvedValue({ + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }) +}) + +describe("documentDelete configured container scope", () => { + it("ai-sdk refuses a model-supplied tag outside the configured scope", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_b", + customId: "doc_in_tenant_b", + containerTags: ["tenant-b"], + }) + + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { containerTags: ["tenant-a"] }), + { documentId: "doc_in_tenant_b", containerTag: "tenant-b" }, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + expect(documentsGet).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(documentsDeleteBulk).not.toHaveBeenCalled() + }) + + it("openai refuses a model-supplied tag outside the configured scope", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_b", + customId: "doc_in_tenant_b", + containerTags: ["tenant-b"], + }) + + const documentDelete = openAi.createDocumentDeleteFunction(API_KEY, { + containerTags: ["tenant-a"], + }) + const result = await documentDelete({ + documentId: "doc_in_tenant_b", + containerTag: "tenant-b", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + expect(documentsGet).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(documentsDeleteBulk).not.toHaveBeenCalled() + }) + + it("rejects tags outside an implicit project scope", async () => { + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { projectId: "alpha" }), + { documentId: "doc_1", containerTag: "tenant-b" }, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + expect(documentsDeleteBulk).not.toHaveBeenCalled() + }) + + it("allows selecting another explicitly configured tag", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_b", + customId: "doc_in_tenant_b", + containerTags: ["tenant-b"], + }) + + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { + containerTags: ["tenant-a", "tenant-b"], + }), + { documentId: "doc_in_tenant_b", containerTag: "tenant-b" }, + ) + + expect(result.success).toBe(true) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ + ids: ["doc_in_tenant_b"], + }) + }) + + it("keeps the configured union when no tag is supplied", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_a", + customId: "doc_in_tenant_a", + containerTags: ["tenant-a"], + }) + + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { containerTags: ["tenant-a"] }), + { documentId: "doc_in_tenant_a" }, + ) + + expect(result.success).toBe(true) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ + ids: ["doc_in_tenant_a"], + }) + }) +}) diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 1be290849..8949aafbe 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -414,6 +414,15 @@ export function createDocumentDeleteFunction( containerTag?: string }): Promise { try { + // A model-supplied tag may only select one of the tags the developer + // configured. Accepting an arbitrary value would let the model delete + // documents outside the configured scope. + if (containerTag && !containerTags.includes(containerTag)) { + throw new Error( + `Container tag "${containerTag}" is outside the configured scope.`, + ) + } + const scopeTags: [string, ...string[]] = containerTag ? [containerTag] : containerTags