mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
fix(tools): enforce configured container scope
This commit is contained in:
parent
e651045ac5
commit
da19beafb2
5 changed files with 166 additions and 9 deletions
|
|
@ -6,6 +6,7 @@ import {
|
||||||
PARAMETER_DESCRIPTIONS,
|
PARAMETER_DESCRIPTIONS,
|
||||||
TOOL_DESCRIPTIONS,
|
TOOL_DESCRIPTIONS,
|
||||||
getContainerTags,
|
getContainerTags,
|
||||||
|
resolveConfiguredContainerTag,
|
||||||
} from "./tools-shared"
|
} from "./tools-shared"
|
||||||
import { forgetMemoryRequest } from "./shared/forget-memory"
|
import { forgetMemoryRequest } from "./shared/forget-memory"
|
||||||
import type { SupermemoryToolsConfig } from "./types"
|
import type { SupermemoryToolsConfig } from "./types"
|
||||||
|
|
@ -141,7 +142,7 @@ export const getProfileTool = (
|
||||||
}),
|
}),
|
||||||
execute: async ({ containerTag, query }) => {
|
execute: async ({ containerTag, query }) => {
|
||||||
try {
|
try {
|
||||||
const tag = containerTag || containerTags[0]
|
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||||
|
|
||||||
const response = await client.profile({
|
const response = await client.profile({
|
||||||
containerTag: tag,
|
containerTag: tag,
|
||||||
|
|
@ -196,7 +197,7 @@ export const documentListTool = (
|
||||||
}),
|
}),
|
||||||
execute: async ({ containerTag, limit, page }) => {
|
execute: async ({ containerTag, limit, page }) => {
|
||||||
try {
|
try {
|
||||||
const tag = containerTag || containerTags[0]
|
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||||
|
|
||||||
const response = await client.documents.list({
|
const response = await client.documents.list({
|
||||||
containerTags: [tag],
|
containerTags: [tag],
|
||||||
|
|
@ -327,12 +328,12 @@ export const memoryForgetTool = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = containerTag || containerTags[0]
|
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||||
|
|
||||||
await forgetMemoryRequest(
|
await forgetMemoryRequest(
|
||||||
apiKey,
|
apiKey,
|
||||||
{
|
{
|
||||||
containerTag: tag as string,
|
containerTag: tag,
|
||||||
...(memoryId && { id: memoryId }),
|
...(memoryId && { id: memoryId }),
|
||||||
...(memoryContent && { content: memoryContent }),
|
...(memoryContent && { content: memoryContent }),
|
||||||
...(reason && { reason }),
|
...(reason && { reason }),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
PARAMETER_DESCRIPTIONS,
|
PARAMETER_DESCRIPTIONS,
|
||||||
TOOL_DESCRIPTIONS,
|
TOOL_DESCRIPTIONS,
|
||||||
getContainerTags,
|
getContainerTags,
|
||||||
|
resolveConfiguredContainerTag,
|
||||||
} from "../tools-shared"
|
} from "../tools-shared"
|
||||||
import { forgetMemoryRequest } from "../shared/forget-memory"
|
import { forgetMemoryRequest } from "../shared/forget-memory"
|
||||||
import type { SupermemoryToolsConfig } from "../types"
|
import type { SupermemoryToolsConfig } from "../types"
|
||||||
|
|
@ -323,7 +324,7 @@ export function createGetProfileFunction(
|
||||||
query?: string
|
query?: string
|
||||||
}): Promise<ProfileResult> {
|
}): Promise<ProfileResult> {
|
||||||
try {
|
try {
|
||||||
const tag = containerTag || containerTags[0]
|
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||||
|
|
||||||
const response = await client.profile({
|
const response = await client.profile({
|
||||||
containerTag: tag,
|
containerTag: tag,
|
||||||
|
|
@ -363,7 +364,7 @@ export function createDocumentListFunction(
|
||||||
page?: number
|
page?: number
|
||||||
}): Promise<DocumentListResult> {
|
}): Promise<DocumentListResult> {
|
||||||
try {
|
try {
|
||||||
const tag = containerTag || containerTags[0]
|
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||||
|
|
||||||
const response = await client.documents.list({
|
const response = await client.documents.list({
|
||||||
containerTags: [tag],
|
containerTags: [tag],
|
||||||
|
|
@ -485,12 +486,12 @@ export function createMemoryForgetFunction(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = containerTag || containerTags[0]
|
const tag = resolveConfiguredContainerTag(containerTags, containerTag)
|
||||||
|
|
||||||
await forgetMemoryRequest(
|
await forgetMemoryRequest(
|
||||||
apiKey,
|
apiKey,
|
||||||
{
|
{
|
||||||
containerTag: tag as string,
|
containerTag: tag,
|
||||||
...(memoryId && { id: memoryId }),
|
...(memoryId && { id: memoryId }),
|
||||||
...(memoryContent && { content: memoryContent }),
|
...(memoryContent && { content: memoryContent }),
|
||||||
...(reason && { reason }),
|
...(reason && { reason }),
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
// executions can be verified deterministically without network access.
|
// executions can be verified deterministically without network access.
|
||||||
const documentsDelete = vi.fn()
|
const documentsDelete = vi.fn()
|
||||||
const documentsList = vi.fn()
|
const documentsList = vi.fn()
|
||||||
|
const profileRequest = vi.fn()
|
||||||
const searchExecute = vi.fn()
|
const searchExecute = vi.fn()
|
||||||
const clientAdd = vi.fn()
|
const clientAdd = vi.fn()
|
||||||
|
|
||||||
vi.mock("supermemory", () => {
|
vi.mock("supermemory", () => {
|
||||||
return {
|
return {
|
||||||
default: class MockSupermemory {
|
default: class MockSupermemory {
|
||||||
|
profile = profileRequest
|
||||||
search = { execute: searchExecute }
|
search = { execute: searchExecute }
|
||||||
add = clientAdd
|
add = clientAdd
|
||||||
documents = {
|
documents = {
|
||||||
|
|
@ -40,11 +42,106 @@ beforeEach(() => {
|
||||||
memories: [{ id: "doc_1", title: "Doc one" }],
|
memories: [{ id: "doc_1", title: "Doc one" }],
|
||||||
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
|
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
|
||||||
})
|
})
|
||||||
|
profileRequest.mockReset().mockResolvedValue({
|
||||||
|
profile: { static: [], dynamic: [] },
|
||||||
|
searchResults: { results: [] },
|
||||||
|
})
|
||||||
searchExecute.mockReset()
|
searchExecute.mockReset()
|
||||||
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
|
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 = (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",
|
||||||
|
}),
|
||||||
|
])) as Array<{ success: boolean; error?: string }>
|
||||||
|
|
||||||
|
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" },
|
||||||
|
)) as { success: boolean; error?: string }
|
||||||
|
|
||||||
|
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", async () => {
|
||||||
|
const result = await openAi.createGetProfileFunction(API_KEY, {
|
||||||
|
containerTags: [],
|
||||||
|
})({})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain(
|
||||||
|
"require at least one configured container tag",
|
||||||
|
)
|
||||||
|
expect(profileRequest).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("documentDelete", () => {
|
describe("documentDelete", () => {
|
||||||
it("ai-sdk variant passes the document id string to the SDK", async () => {
|
it("ai-sdk variant passes the document id string to the SDK", async () => {
|
||||||
const tool = aiSdk.documentDeleteTool(API_KEY)
|
const tool = aiSdk.documentDeleteTool(API_KEY)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { deduplicateMemoriesForMode, getContainerTags } from "./tools-shared"
|
import {
|
||||||
|
deduplicateMemoriesForMode,
|
||||||
|
getContainerTags,
|
||||||
|
resolveConfiguredContainerTag,
|
||||||
|
} from "./tools-shared"
|
||||||
|
|
||||||
describe("getContainerTags", () => {
|
describe("getContainerTags", () => {
|
||||||
it("uses the default project when no config is provided", () => {
|
it("uses the default project when no config is provided", () => {
|
||||||
|
|
@ -27,6 +31,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", () => {
|
describe("deduplicateMemoriesForMode", () => {
|
||||||
// The profile is not injected in "query" mode, so a memory that is both a
|
// 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 —
|
// profile fact and a search hit must survive in the search results —
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,34 @@ export function getContainerTags(config?: {
|
||||||
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags
|
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a model-supplied container tag without allowing it to escape the
|
||||||
|
* developer-configured 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 === undefined) {
|
||||||
|
return defaultTag
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configuredTags.includes(requestedTag)) {
|
||||||
|
throw new Error(
|
||||||
|
`Container tag "${requestedTag}" is outside the configured scope.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestedTag
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Memory item interface representing a single memory with optional metadata
|
* Memory item interface representing a single memory with optional metadata
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue