fix(tools): prevent customId collisions in claude memory tool (#1547)

This commit is contained in:
Aditya kumar singh 2026-09-17 16:27:11 +05:30
parent c927c98f2c
commit a5a5dc85c6
3 changed files with 60 additions and 6 deletions

View file

@ -210,3 +210,42 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
expect(stored).toContain(`price is ${dollarSequence} today`)
})
})
describe("ClaudeMemoryTool path normalization collision resistance", () => {
it("produces distinct customIds for paths that previously collided", () => {
const tool = new ClaudeMemoryTool("test-api-key")
const paths = [
"/memories/notes.txt",
"/memories/notes_txt",
"/memories/notes/txt",
"/memories/project/a.md",
"/memories/project_a.md",
]
const ids = paths.map((path) => tool.normalizePathToCustomId(path))
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(paths.length)
})
it("resolves documents stored under legacy customId format", async () => {
// Mock a document saved with legacy normalization (memories_notes_txt)
mockDocuments([
{
id: "legacy-doc",
customId: "memories_notes_txt",
filePath: "/memories/notes.txt",
content: "legacy content",
},
])
const tool = new ClaudeMemoryTool("test-api-key")
const result = await tool.handleCommand({
command: "view",
path: "/memories/notes.txt",
})
expect(result.success).toBe(true)
expect(result.content).toContain("legacy content")
})
})

View file

@ -57,13 +57,22 @@ export class ClaudeMemoryTool {
/**
* Normalize file path to be used as customId
* Converts /memories/file.txt -> memories_file_txt
* Reversibly encodes path components to prevent collisions between paths like
* `/memories/notes.txt`, `/memories/notes_txt`, and `/memories/notes/txt`.
*/
private normalizePathToCustomId(path: string): string {
normalizePathToCustomId(path: string): string {
return path
.replace(/^\//, "") // Remove leading slash
.replace(/\//g, "_") // Replace / with _
.replace(/\./g, "_") // Replace . with _
.replace(/_/g, "__") // Escape underscores: _ -> __
.replace(/\//g, "_s_") // Encode slashes: / -> _s_
.replace(/\./g, "_d_") // Encode dots: . -> _d_
}
/**
* Legacy normalization used in older versions (/ and . both flattened to _)
*/
private legacyNormalizePathToCustomId(path: string): string {
return path.replace(/^\//, "").replace(/\//g, "_").replace(/\./g, "_")
}
constructor(apiKey: string, config?: ClaudeMemoryConfig) {
@ -650,8 +659,12 @@ export class ClaudeMemoryTool {
})
for (const document of response.memories) {
const isMatchingCustomId =
document.customId === normalizedId ||
document.customId === this.legacyNormalizePathToCustomId(filePath)
if (
document.customId === normalizedId &&
isMatchingCustomId &&
this.getDocumentFilePath(document) === filePath &&
this.isDocumentInConfiguredScope(document)
) {

View file

@ -344,7 +344,9 @@ describe("ClaudeMemoryTool", () => {
expect(result.success).toBe(true)
expect(clientAdd).toHaveBeenCalledWith(
expect.objectContaining({ customId: "memories_renamed_txt" }),
expect.objectContaining({
customId: tool.normalizePathToCustomId("/memories/renamed.txt"),
}),
)
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
})