brain_read returns a note verbatim; brain_edit is str_replace with an exactly-once match. Both operate on INGEST_BRAIN_DIR, which is the pod's mount of /var/lib/supervisor/brain — the tree brain-sync commits from, so an edit reaches gitea on the next cycle rather than landing somewhere the sync cannot see it (brain#2). An adversarial design review before implementation changed three things, each of which would otherwise have shipped: 1. Governance files are NOT in the model-facing tool. The original plan allowed root *.md so CLAUDE.md had an MCP path at all — the absence of one is what pushed a session to edit via the gitea REST API on 2026-06-16 and wedge the sync. But brain content is ingested from the outside world, reaches a model's context via brain_query, and that model would hold the editor: one poisoned page could rewrite the CLAUDE.md a .dispatch-allow agent later loads as instructions. Worse, brain-sync merges with --strategy-option=ours, so the live tree wins against a human's corrective push on exactly the conflicting lines. CLAUDE.md now goes through brain_edit_root: de-surfaced from tools/list (the brain_index precedent, ADR-0012 rule 4) and gated on the local static principal, so a claude.ai-origin session is refused. The two scopes are disjoint — the privileged door has the smaller reach. 2. Reads needed the same allowlist, and brain_get already lacked it. api.ReadNote resolves any brain-relative path and parseFrontmatter returns the whole file when there is no fence, so brain_get reads verbatim session transcripts under archive/ (up to 500 KB, never BM25-indexed, so nobody thinks of them as reachable). A test proves the exposure was live; the same Resolve now gates brain_get. 3. Canonicalise before allowlisting. A prefix check on the raw argument accepts knowledge/../.git/hooks/post-commit, which Clean collapses to a git hook that brain-sync executes inside a pod holding the Gitea push credentials. Traversal, symlink escape, dotfiles, .git/, archive/, sessions/ and raw/ are refused by a static backstop that does not depend on the allowlist being right. There were no traversal tests in this repo before. Also from the review: overlapping match counting (bytes.Count says "aa" occurs once in "aaa", so a Count-based uniqueness check accepts an ambiguous edit); UTF-8 validation on both sides, because encoding/json substitutes U+FFFD and would hand back bytes the file does not contain, making every derived old_str unmatchable; refusal while .git/MERGE_HEAD or index.lock exists, since an edit racing brain-sync's merge is either hard-reset away or committed as a wrongly resolved conflict; flock plus a per-path mutex with the read taken inside the lock, because two pods share the hostPath during a RollingUpdate and uniqueness evaluated against a stale read loses an update while reporting success; same-dir *.tmp + rename + chmod 0644 + fsync of file and parent; and reindex only when the edit touches frontmatter, since BM25 walks live and embeddings are mtime-driven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
147 lines
5.0 KiB
Go
147 lines
5.0 KiB
Go
package notes
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newBrain lays out a tree shaped like the live one: a git dir, note dirs,
|
|
// and the non-note areas that must never be reachable.
|
|
func newBrain(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, d := range []string{
|
|
".git/hooks", "knowledge", "wiki/homelab/facts", "archive/claude-sessions/koala",
|
|
"sessions", "raw", ".obsidian",
|
|
} {
|
|
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write := func(p, s string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, p), []byte(s), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("CLAUDE.md", "# instructions\n")
|
|
write("log.md", "ignored scratch\n")
|
|
write("knowledge/note.md", "# note\n")
|
|
write("wiki/homelab/facts/f.md", "# fact\n")
|
|
write("wiki/homelab/_index.md", "# index\n")
|
|
write("archive/claude-sessions/koala/session-1.md", "verbatim transcript\n")
|
|
write("sessions/s.jsonl", "{}\n")
|
|
write(".git/hooks/post-commit", "#!/bin/sh\n")
|
|
return dir
|
|
}
|
|
|
|
func TestResolve_AcceptsNotePaths(t *testing.T) {
|
|
dir := newBrain(t)
|
|
for _, p := range []string{"knowledge/note.md", "wiki/homelab/facts/f.md"} {
|
|
got, err := Resolve(dir, p, ScopeNotes)
|
|
if err != nil {
|
|
t.Errorf("Resolve(%q): %v", p, err)
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(got.Abs, dir) {
|
|
t.Errorf("Resolve(%q).Abs = %q, want inside %q", p, got.Abs, dir)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolve_RefusesEscapesAndNonNoteAreas(t *testing.T) {
|
|
dir := newBrain(t)
|
|
|
|
// Canonicalisation must happen BEFORE the allowlist check. A prefix test
|
|
// on the raw argument accepts every one of these: "knowledge/..%2f" style
|
|
// inputs collapse to .git, and .git/hooks/post-commit is executed by
|
|
// brain-sync inside a pod holding the Gitea push credentials.
|
|
refuse := []string{
|
|
"knowledge/../.git/hooks/post-commit",
|
|
"wiki/../.git/config",
|
|
"./knowledge/../.git/config",
|
|
"knowledge//../.git/config",
|
|
"../../etc/passwd",
|
|
"/etc/passwd",
|
|
".git/config",
|
|
"archive/claude-sessions/koala/session-1.md", // verbatim transcripts
|
|
"sessions/s.jsonl",
|
|
"raw/x.md",
|
|
".obsidian/workspace.json",
|
|
"log.md", // gitignored: an edit here would never sync (brain#2)
|
|
"wiki/homelab/_index.md", // generated; brain-sync resolves it in origin's favour
|
|
"knowledge/note.txt", // not markdown
|
|
"CLAUDE.md", // root file — not reachable in the notes scope
|
|
}
|
|
for _, p := range refuse {
|
|
if _, err := Resolve(dir, p, ScopeNotes); err == nil {
|
|
t.Errorf("Resolve(%q) = nil error, want refusal", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolve_RootScopeAdmitsOnlyNamedFiles(t *testing.T) {
|
|
dir := newBrain(t)
|
|
|
|
if _, err := Resolve(dir, "CLAUDE.md", ScopeRoot); err != nil {
|
|
t.Errorf("Resolve(CLAUDE.md, ScopeRoot): %v", err)
|
|
}
|
|
// log.md is the reason this is an exact-name list and not "root *.md":
|
|
// it is gitignored, so an edit would apply locally and never reach gitea.
|
|
for _, p := range []string{"log.md", "AGENTS.md", "knowledge/note.md", ".git/config"} {
|
|
if _, err := Resolve(dir, p, ScopeRoot); err == nil {
|
|
t.Errorf("Resolve(%q, ScopeRoot) = nil error, want refusal", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolve_RefusesSymlinkedTargets(t *testing.T) {
|
|
dir := newBrain(t)
|
|
outside := filepath.Join(t.TempDir(), "secret.md")
|
|
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The tree is Syncthing-managed, so a symlink can appear without anyone
|
|
// adding one deliberately. Containment must survive it.
|
|
if err := os.Symlink(outside, filepath.Join(dir, "knowledge/link.md")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
if _, err := Resolve(dir, "knowledge/link.md", ScopeNotes); err == nil {
|
|
t.Error("Resolve followed a symlink out of the brain dir")
|
|
}
|
|
|
|
if err := os.Symlink(filepath.Dir(outside), filepath.Join(dir, "wiki/escape")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
if _, err := Resolve(dir, "wiki/escape/secret.md", ScopeNotes); err == nil {
|
|
t.Error("Resolve followed a symlinked directory out of the brain dir")
|
|
}
|
|
}
|
|
|
|
func TestResolve_RefusesWhenTheTreeIsNotTheSyncSource(t *testing.T) {
|
|
// brain#2 was "wrote to a directory the sync does not commit from". No
|
|
// code review prevents that recurring — a wrong INGEST_BRAIN_DIR in a
|
|
// future manifest reintroduces it and every unit test still passes. So
|
|
// assert the property at runtime, the way brain-sync itself does.
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "knowledge/note.md"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Reads are fine anywhere; the guard belongs to writes.
|
|
if _, err := Resolve(dir, "knowledge/note.md", ScopeNotes); err != nil {
|
|
t.Fatalf("Resolve should not require .git for a read: %v", err)
|
|
}
|
|
_, err := Edit(dir, "knowledge/note.md", "x", "y", ScopeNotes)
|
|
if err == nil {
|
|
t.Fatal("Edit accepted a tree with no .git — the write would never be committed")
|
|
}
|
|
if !strings.Contains(err.Error(), "sync source") {
|
|
t.Errorf("error should name the cause, got %v", err)
|
|
}
|
|
}
|