feat(mcp): brain_read + brain_edit, contained to the sync source (brain#4) (#96)
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Skipped

brain_read returns a note verbatim; brain_edit is str_replace with an
exactly-once match, operating on the tree brain-sync commits from.

An adversarial design review before implementation changed three things:

1. Governance files are not in the model-facing tool. Brain content is
   ingested from the outside world and reaches a model's context, and that
   model would hold the editor — one poisoned page could rewrite the
   CLAUDE.md a .dispatch-allow agent later loads as instructions. And
   brain-sync merges with --strategy-option=ours, so the live tree beats a
   human's corrective push on the conflicting lines. CLAUDE.md now goes
   through brain_edit_root: de-surfaced from tools/list and gated on the
   local static principal. The scopes are disjoint — the privileged door
   has the smaller reach.

2. Reads needed the same allowlist, and brain_get already lacked it: it
   returned verbatim session transcripts under archive/ by path. A test
   proved the exposure live before the fix.

3. Canonicalise before allowlisting — knowledge/../.git/hooks/post-commit
   would otherwise reach a hook that brain-sync executes in a pod holding
   the Gitea push credentials.

Plus overlapping match counting, UTF-8 validation both ways, refusal while
git is mid-operation, flock with the read inside the lock, atomic
same-dir rename, and reindex only when frontmatter is touched.

Refs brain#4.

Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #96.
This commit is contained in:
2026-08-18 18:14:05 +00:00
committed by mathias
parent eb9e3e69a9
commit 055af368cd
11 changed files with 1185 additions and 5 deletions
+146
View File
@@ -0,0 +1,146 @@
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)
}
}