From f9a09642bf94d38052efddc38c647c2fa7aaab46 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:29:05 +0000 Subject: [PATCH] fix(tools): keep model-supplied containerTag inside the configured scope (2.3.1) getProfile, documentList, memoryForget and documentDelete accept an optional containerTag from the model and used it as the request scope unchecked, so a model or prompt-injected input could read profiles, list documents, forget memories or delete documents under another tenant's tag. Both the AI SDK and OpenAI tool surfaces now resolve that tag through resolveConfiguredContainerTag, which only allows tags the developer configured and rejects anything else before any request is made. Backport of #1504 (profile, list, forget) and #1758 (documentDelete) onto the 2.3.0 release commit, with their regression tests. Co-authored-by: abhinav7x94 Co-authored-by: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Co-authored-by: Atharvsinh-codez Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- packages/tools/package.json | 4 +- packages/tools/src/ai-sdk.ts | 11 +- .../tools/src/document-delete-scope.test.ts | 139 ++++++++++++++++++ packages/tools/src/openai/tools.ts | 11 +- packages/tools/src/tool-operations.test.ts | 110 +++++++++++++- packages/tools/src/tools-shared.test.ts | 27 ++++ packages/tools/src/tools-shared.ts | 25 ++++ 7 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 packages/tools/src/document-delete-scope.test.ts diff --git a/packages/tools/package.json b/packages/tools/package.json index 05f6b6f26..441b5399c 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,14 +1,14 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.3.0", + "version": "2.3.1", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/document-delete-scope.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 97d6a36bf..6a9adb844 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -10,6 +10,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" import type { SupermemoryToolsConfig } from "./types" @@ -146,7 +147,7 @@ export const getProfileTool = ( }), execute: async ({ containerTag, query }) => { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -199,7 +200,7 @@ export const documentListTool = ( execute: async ({ containerTag, limit, page }) => { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -248,7 +249,7 @@ export const documentDeleteTool = ( execute: async ({ documentId, containerTag }) => { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags await deleteDocumentByIdentifier(client, documentId, scopeTags) @@ -339,12 +340,12 @@ export const memoryForgetTool = ( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), 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..e209a41c5 --- /dev/null +++ b/packages/tools/src/document-delete-scope.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +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..7a432dc6a 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -9,6 +9,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" import type { SupermemoryToolsConfig } from "../types" @@ -333,7 +334,7 @@ export function createGetProfileFunction( query?: string }): Promise { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -374,7 +375,7 @@ export function createDocumentListFunction( }): Promise { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -415,7 +416,7 @@ export function createDocumentDeleteFunction( }): Promise { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags await deleteDocumentByIdentifier(client, documentId, scopeTags) @@ -502,12 +503,12 @@ export function createMemoryForgetFunction( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 8efef91d1..280d34061 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const documentsDeleteBulk = vi.fn() const documentsGet = vi.fn() const documentsList = vi.fn() +const profileRequest = vi.fn() const clientAdd = vi.fn() const clientSearch = vi.fn() const clientOptions: unknown[] = [] @@ -15,6 +16,7 @@ vi.mock("supermemory", () => { constructor(options: unknown) { clientOptions.push(options) } + profile = profileRequest add = clientAdd search = clientSearch documents = { @@ -34,9 +36,15 @@ import * as openAi from "./openai/tools" const API_KEY = "sm_test_key" -type ToolWithExecute = { execute: (args: Record) => unknown } +type ToolExecutionResult = { success: boolean; error?: string } +type ToolWithExecute = { + execute: (args: Record) => Promise +} -function executeTool(tool: unknown, args: Record) { +function executeTool( + tool: unknown, + args: Record, +): Promise { return (tool as ToolWithExecute).execute(args) } @@ -55,6 +63,10 @@ beforeEach(() => { memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) + profileRequest.mockReset().mockResolvedValue({ + profile: { static: [], dynamic: [] }, + searchResults: { results: [] }, + }) clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) clientSearch.mockReset().mockResolvedValue({ results: [] }) clientOptions.length = 0 @@ -112,12 +124,96 @@ describe("searchMemories", () => { }) }) +describe("configured container scope", () => { + it("rejects out-of-scope tags across both tool surfaces before I/O", async () => { + const config = { containerTags: ["tenant-a"] } + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + + const results: ToolExecutionResult[] = await Promise.all([ + executeTool(aiSdk.getProfileTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createGetProfileFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.documentListTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createDocumentListFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.memoryForgetTool(API_KEY, config), { + containerTag: "tenant-b", + memoryId: "mem_1", + }), + openAi.createMemoryForgetFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + memoryId: "mem_1", + }), + ]) + + expect(results).toHaveLength(6) + for (const result of results) { + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + } + expect(profileRequest).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("allows selecting another explicitly configured tag", async () => { + const getProfile = openAi.createGetProfileFunction(API_KEY, { + containerTags: ["tenant-a", "tenant-b"], + }) + + const result = await getProfile({ containerTag: "tenant-b" }) + + expect(result.success).toBe(true) + expect(profileRequest).toHaveBeenCalledWith({ + containerTag: "tenant-b", + }) + }) + + it("does not let model input override implicit or project scopes", async () => { + const implicitResult = await openAi.createDocumentListFunction(API_KEY)({ + containerTag: "tenant-b", + }) + const projectResult = await executeTool( + aiSdk.getProfileTool(API_KEY, { projectId: "alpha" }), + { containerTag: "tenant-b" }, + ) + + expect(implicitResult.success).toBe(false) + expect(implicitResult.error).toContain("outside the configured scope") + expect(projectResult.success).toBe(false) + expect(projectResult.error).toContain("outside the configured scope") + expect(documentsList).not.toHaveBeenCalled() + expect(profileRequest).not.toHaveBeenCalled() + }) + + it("fails closed when the configured scope is empty", () => { + expect(() => + openAi.createGetProfileFunction(API_KEY, { containerTags: [] }), + ).toThrow("at least one non-empty containerTag") + expect(profileRequest).not.toHaveBeenCalled() + }) +}) + describe("documentDelete", () => { it("ai-sdk variant passes the document id string to the SDK", async () => { const tool = aiSdk.documentDeleteTool(API_KEY) - const result = (await executeTool(tool, { documentId: "doc_123" })) as { - success: boolean - } + const result = await executeTool(tool, { documentId: "doc_123" }) expect(result.success).toBe(true) expect(documentsGet).toHaveBeenCalledWith("doc_123") @@ -209,9 +305,9 @@ describe("memoryForget", () => { containerTags: ["user_2"], }) - const result = (await executeTool(tool, { + const result = await executeTool(tool, { memoryContent: "stale fact", - })) as { success: boolean } + }) expect(result.success).toBe(true) const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 3cc070ba7..d92c7defb 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -5,6 +5,7 @@ import { clampSearchLimit, deduplicateMemoriesForMode, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" describe("clampSearchLimit", () => { @@ -58,6 +59,32 @@ describe("getContainerTags", () => { }) }) +describe("resolveConfiguredContainerTag", () => { + it("defaults to the first configured tag", () => { + expect(resolveConfiguredContainerTag(["tenant-a", "tenant-b"])).toBe( + "tenant-a", + ) + }) + + it("allows selection within a multi-tag scope", () => { + expect( + resolveConfiguredContainerTag(["tenant-a", "tenant-b"], "tenant-b"), + ).toBe("tenant-b") + }) + + it("rejects tags outside the configured scope", () => { + expect(() => + resolveConfiguredContainerTag(["tenant-a"], "tenant-b"), + ).toThrow('Container tag "tenant-b" is outside the configured scope') + }) + + it("rejects an empty configured scope", () => { + expect(() => resolveConfiguredContainerTag([])).toThrow( + "require at least one configured container tag", + ) + }) +}) + describe("deduplicateMemoriesForMode", () => { // The profile is not injected in "query" mode, so a memory that is both a // profile fact and a search hit must survive in the search results — diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index f4d150ca1..2b9baca73 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -125,6 +125,31 @@ export function getContainerTags(config?: { return [...CONTAINER_TAG_CONSTANTS.defaultTags] } +// A model-supplied tag may only pick one of the developer-configured tags, never widen the scope. +export function resolveConfiguredContainerTag( + configuredTags: readonly string[], + requestedTag?: string, +): string { + const defaultTag = configuredTags[0] + if (defaultTag === undefined) { + throw new Error( + "Supermemory tools require at least one configured container tag.", + ) + } + + if (!requestedTag) { + return defaultTag + } + + if (!configuredTags.includes(requestedTag)) { + throw new Error( + `Container tag "${requestedTag}" is outside the configured scope.`, + ) + } + + return requestedTag +} + /** Delete exactly one document by its internal ID. */ export async function deleteDocumentById( client: Supermemory,