mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-26 06:04:08 +00:00
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:
parent
d0f53b0d64
commit
f9a09642bf
7 changed files with 308 additions and 19 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
139
packages/tools/src/document-delete-scope.test.ts
Normal file
139
packages/tools/src/document-delete-scope.test.ts
Normal 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"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue