Skip to content

Commit ffc778e

Browse files
committed
feat(memory): durable note store and memory tool
Split 2/3 of #829, stacked on the pathjail primitive from #891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file, and the final component is checked for a reparse point before each write. - internal/tools/memory.go: the memory tool over that store. Note names are an ALLOW-LIST matching the plan store's rule (^[a-z][a-z0-9-]{0,63}$), and lowercase-only is load bearing: Windows and a default APFS volume fold case, so "Findings" and "findings" would be one file there — the second write silently replacing the first's body and a delete of one removing the other. Reserved DOS device names are refused on top of the pattern, because os.Root addresses notes relative to a handle and so creates "con.md" happily, while `git add -A` then fails on that path and stages nothing, and a repo carrying one cannot be checked out on Windows at all. Frontmatter parses CRLF as well as LF: project notes are checked in, and Git for Windows defaults to autocrlf=true, so a note that merely round-trips through a clone came back with its header rendered as body and no description. Registration into the tool registry lands with the orchestration wiring in 3/3, where the shared registry changes live.
1 parent 7193cac commit ffc778e

4 files changed

Lines changed: 885 additions & 0 deletions

File tree

‎internal/memory/escape_test.go‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package memory
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// linkDir uses a junction on Windows: it needs no privilege, unlike a symlink,
13+
// so it is both the reachable attack and the only one testable on an ordinary
14+
// Windows account.
15+
func linkDir(t *testing.T, target, link string) {
16+
t.Helper()
17+
if runtime.GOOS == "windows" {
18+
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
19+
t.Skipf("cannot create a junction: %v %s", err, out)
20+
}
21+
return
22+
}
23+
if err := os.Symlink(target, link); err != nil {
24+
t.Skipf("cannot create a symlink: %v", err)
25+
}
26+
}
27+
28+
// The store used to check only its own directory and file, so a link at the
29+
// ANCESTOR .zero turned every operation into one aimed outside the workspace:
30+
// Write and Forget were arbitrary write and delete, and Read was an arbitrary
31+
// read in a tool the model can call by name.
32+
//
33+
// All three are asserted, because fixing one and leaving the others is exactly
34+
// what the original guard did.
35+
func TestAnAncestorLinkCannotTakeTheStoreOutOfTheWorkspace(t *testing.T) {
36+
base := t.TempDir()
37+
outside := filepath.Join(base, "outside")
38+
workspace := filepath.Join(base, "workspace")
39+
for _, dir := range []string{outside, workspace} {
40+
if err := os.MkdirAll(dir, 0o700); err != nil {
41+
t.Fatal(err)
42+
}
43+
}
44+
// A note already sitting in the external directory, so a successful read
45+
// would be visible rather than merely "no error".
46+
if err := os.MkdirAll(filepath.Join(outside, "memory"), 0o700); err != nil {
47+
t.Fatal(err)
48+
}
49+
secret := filepath.Join(outside, "memory", "secret.md")
50+
if err := os.WriteFile(secret, []byte("do not read me"), 0o600); err != nil {
51+
t.Fatal(err)
52+
}
53+
linkDir(t, outside, filepath.Join(workspace, ".zero"))
54+
55+
paths := DefaultPaths(workspace)
56+
57+
if _, err := Write(paths, ScopeProject, "escaped", "d", "b"); err == nil {
58+
t.Error("Write went through the linked ancestor")
59+
}
60+
if _, err := os.Stat(filepath.Join(outside, "memory", "escaped.md")); !os.IsNotExist(err) {
61+
t.Errorf("a note was written outside the workspace, stat error = %v", err)
62+
}
63+
if _, err := Read(paths, ScopeProject, "secret"); err == nil {
64+
t.Error("Read returned a note from outside the workspace")
65+
}
66+
if err := Forget(paths, ScopeProject, "secret"); err == nil {
67+
t.Error("Forget accepted a target outside the workspace")
68+
}
69+
if _, err := os.Stat(secret); err != nil {
70+
t.Errorf("Forget deleted a file outside the workspace: %v", err)
71+
}
72+
if notes := List(paths); len(notes) != 0 {
73+
t.Errorf("List surfaced %d note(s) from outside the workspace", len(notes))
74+
}
75+
}
76+
77+
// The ordinary path still works, or the test above would pass against a store
78+
// that refused everything.
79+
func TestAnOrdinaryWorkspaceStoreStillRoundTrips(t *testing.T) {
80+
workspace := t.TempDir()
81+
paths := DefaultPaths(workspace)
82+
if _, err := Write(paths, ScopeProject, "note", "a summary", "the body"); err != nil {
83+
t.Fatalf("Write: %v", err)
84+
}
85+
note, err := Read(paths, ScopeProject, "note")
86+
if err != nil {
87+
t.Fatalf("Read: %v", err)
88+
}
89+
if note.Description != "a summary" || strings.TrimSpace(note.Body) != "the body" {
90+
t.Errorf("round trip lost content: %+v", note)
91+
}
92+
if notes := List(paths); len(notes) != 1 {
93+
t.Errorf("List returned %d notes, want 1", len(notes))
94+
}
95+
if err := Forget(paths, ScopeProject, "note"); err != nil {
96+
t.Fatalf("Forget: %v", err)
97+
}
98+
if _, err := Read(paths, ScopeProject, "note"); err == nil {
99+
t.Error("the note survived Forget")
100+
}
101+
}

0 commit comments

Comments
 (0)