Skip to content

Commit 1928c10

Browse files
committed
fix(memory): refuse deletion after read failure
1 parent d486d3c commit 1928c10

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

‎internal/tools/memory.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,14 @@ func (tool memoryForgetTool) Run(_ context.Context, args map[string]any) Result
270270
// missing note is not an error at the store layer — but saying "Forgot" for a
271271
// note that never existed tells a model which misspelled the name that the
272272
// deletion happened, and it stops looking for the real one.
273-
if _, err := memory.Read(tool.paths, scope, name); errors.Is(err, memory.ErrNotFound) {
274-
return okResult(fmt.Sprintf("No note named %q in %s, so there was nothing to forget.", name, scope))
273+
if _, readErr := memory.Read(tool.paths, scope, name); readErr != nil {
274+
if errors.Is(readErr, memory.ErrNotFound) {
275+
return okResult(fmt.Sprintf("No note named %q in %s, so there was nothing to forget.", name, scope))
276+
}
277+
// Read-before-delete is a safety gate, not only an existence probe. If the
278+
// note cannot be read, deleting it would destroy the only copy without the
279+
// caller ever being able to inspect what the destructive tool removed.
280+
return errorResult(fmt.Sprintf("Error: cannot read memory %q in %s before deleting it: %v", name, scope, readErr))
275281
}
276282
if err := memory.Forget(tool.paths, scope, name); err != nil {
277283
return errorResult("Error: " + err.Error())

‎internal/tools/memory_test.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,33 @@ func TestMemoryForgetReportsAbsenceRatherThanClaimingSuccess(t *testing.T) {
156156
}
157157
}
158158

159+
func TestMemoryForgetPreservesANoteThatCannotBeRead(t *testing.T) {
160+
paths := memoryTestPaths(t)
161+
if err := os.MkdirAll(paths.LocalDir, 0o700); err != nil {
162+
t.Fatal(err)
163+
}
164+
notePath := filepath.Join(paths.LocalDir, "unreadable.md")
165+
// Oversize input is a deterministic, cross-platform read failure; unlike
166+
// permission bits, it behaves the same when tests run with elevated access.
167+
if err := os.WriteFile(notePath, []byte(strings.Repeat("x", 70<<10)), 0o600); err != nil {
168+
t.Fatal(err)
169+
}
170+
if _, err := memory.Read(paths, memory.ScopeLocal, "unreadable"); err == nil {
171+
t.Fatal("oversized fixture unexpectedly remained readable")
172+
}
173+
174+
result := NewMemoryForgetTool(paths).Run(context.Background(), map[string]any{"name": "unreadable"})
175+
if result.Status != StatusError {
176+
t.Fatalf("memory_forget deleted an unreadable note: %q", result.Output)
177+
}
178+
if !strings.Contains(strings.ToLower(result.Output), "cannot read") {
179+
t.Fatalf("memory_forget hid the read failure: %q", result.Output)
180+
}
181+
if _, err := os.Stat(notePath); err != nil {
182+
t.Fatalf("memory_forget removed the unreadable note: %v", err)
183+
}
184+
}
185+
159186
// One unreadable note must not empty the listing. memory.List deliberately
160187
// returns what it could read alongside the failures; the tool returning an error
161188
// instead threw that away, turning partial success back into total failure one

0 commit comments

Comments
 (0)