From b5670bfd3e031f9b9feb62d94d879a54556da683 Mon Sep 17 00:00:00 2001 From: Atharvsinh-codez Date: Sun, 4 Oct 2026 18:39:39 +0000 Subject: [PATCH] fix(tools): reject out-of-scope containerTag in documentDelete documentDelete takes an optional containerTag from the model and uses it as the scope for the delete. getProfile, documentList and memoryForget only accept tags the developer configured, but documentDelete never checked, so the model could name any tag and delete documents in it. This is the same gap in both tool surfaces (ai-sdk documentDeleteTool and the OpenAI createDocumentDeleteFunction). Both now reject a tag that is not in the configured container tags before making any request, with the same "outside the configured scope" error the other tools use. Added tests for both surfaces, the implicit projectId scope, selecting another configured tag, and the default no-tag path. --- packages/tools/src/ai-sdk.ts | 9 ++ .../tools/src/document-delete-scope.test.ts | 141 ++++++++++++++++++ packages/tools/src/openai/tools.ts | 9 ++ 3 files changed, 159 insertions(+) create mode 100644 packages/tools/src/document-delete-scope.test.ts 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