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 <raj.abhinav7x94@gmail.com>
Co-authored-by: shamAnimates <145093437+shamAnimates@users.noreply.github.com>
Co-authored-by: Atharvsinh-codez <at41rv@gmail.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
MaheshtheDev 2026-10-16 22:29:05 +00:00
parent d0f53b0d64
commit f9a09642bf
7 changed files with 308 additions and 19 deletions

View file

@ -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": {

View file

@ -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 }),

View file

@ -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<string, unknown>) => Promise<ToolExecutionResult>
}
function executeTool(
tool: unknown,
args: Record<string, unknown>,
): Promise<ToolExecutionResult> {
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"],
})
})
})

View file

@ -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<ProfileResult> {
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<DocumentListResult> {
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<DocumentDeleteResult> {
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 }),

View file

@ -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<string, unknown>) => unknown }
type ToolExecutionResult = { success: boolean; error?: string }
type ToolWithExecute = {
execute: (args: Record<string, unknown>) => Promise<ToolExecutionResult>
}
function executeTool(tool: unknown, args: Record<string, unknown>) {
function executeTool(
tool: unknown,
args: Record<string, unknown>,
): Promise<ToolExecutionResult> {
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]

View file

@ -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 —

View file

@ -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,