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

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
This commit is contained in:
2026-08-18 17:01:37 +02:00
co-authored by Claude Opus 5
parent db00644cab
commit 36b757fabf
11 changed files with 1185 additions and 5 deletions
+201
View File
@@ -0,0 +1,201 @@
// Package notes provides containment-checked read and surgical edit of brain
// notes on the live tree that brain-sync commits from.
//
// Everything here is defensive for one reason: brain content is ingested from
// the outside world, that content reaches a model's context via brain_query /
// brain_context, and these primitives are callable by that model. A path bug
// is therefore not a path bug — it is an outside author choosing a file.
package notes
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Scope is which part of the tree a caller is allowed to touch.
type Scope int
const (
// ScopeNotes is the model-facing scope: markdown under knowledge/ and
// wiki/ only.
ScopeNotes Scope = iota
// ScopeRoot admits the named governance files and NOTHING else. It exists so
// there is an MCP path to CLAUDE.md at all — the absence of one is what
// pushed a session to edit via the gitea REST API on 2026-06-16, which
// never reached the live tree and wedged brain-sync for hours. It is NOT
// reachable from the model-facing toolset; see tools_notes.go.
ScopeRoot
)
// rootFiles is an exact-name allowlist, deliberately not a "root *.md" glob.
//
// Two reasons. log.md is gitignored, so an edit to it applies to the live tree
// and never reaches gitea — the exact shape of brain#2, admitted by a glob. And
// a glob auto-admits whatever governance file appears next (AGENTS.md, a
// bootstrap doc) with nobody reviewing that decision.
var rootFiles = map[string]bool{"CLAUDE.md": true}
// noteDirs are the two trees that hold notes.
var noteDirs = []string{"knowledge/", "wiki/"}
// deniedPrefixes are refused in every scope, independent of the allowlist.
//
// A static backstop rather than a consequence of the allowlist being right:
// .git/hooks/post-commit is executed by brain-sync inside a pod holding the
// Gitea push credentials, and archive/ + sessions/ hold verbatim session
// transcripts that BM25 never indexes and so nobody thinks of as reachable.
var deniedPrefixes = []string{".git/", "archive/", "sessions/", "raw/", "training-data/", ".obsidian/"}
// Errors are sentinels so callers can distinguish a refusal from an I/O fault.
var (
ErrOutsideBrain = errors.New("notes: path escapes the brain dir")
ErrNotAllowed = errors.New("notes: path is not editable through this tool")
ErrNotSyncSource = errors.New("notes: brain dir is not the sync source")
)
// Target is a resolved, contained path.
type Target struct {
Rel string // brain-relative, cleaned
Abs string // absolute, symlink-checked
}
// Resolve canonicalises path, then checks it against the scope's allowlist,
// then re-checks containment after symlink evaluation.
//
// Order matters and is the point: a prefix test on the raw argument accepts
// "knowledge/../.git/hooks/post-commit", because Clean only collapses it
// afterwards.
func Resolve(brainDir, path string, scope Scope) (Target, error) {
// Note: the sync-source assertion lives on the WRITE path (Edit), not
// here. brain#2 was an edit landing where the sync could not commit it; a
// read from such a tree is merely a read.
// 1. Canonicalise. Rooting at "/" makes Clean collapse every ".." that
// would otherwise escape, and ToSlash normalises separators first.
clean := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(path)), "/")
if clean == "" || clean == "." {
return Target{}, fmt.Errorf("%w: empty path", ErrNotAllowed)
}
// 2. Static denials, before any allowlist reasoning.
for _, d := range deniedPrefixes {
if strings.HasPrefix(clean+"/", d) {
return Target{}, fmt.Errorf("%w: %q is not note content", ErrNotAllowed, clean)
}
}
if strings.HasPrefix(filepath.Base(clean), ".") {
return Target{}, fmt.Errorf("%w: dotfiles are not editable", ErrNotAllowed)
}
// 3. Scope allowlist.
if err := allow(clean, scope); err != nil {
return Target{}, err
}
// 4. Containment, lexically.
abs := filepath.Join(brainDir, filepath.FromSlash(clean))
if !within(brainDir, abs) {
return Target{}, fmt.Errorf("%w: %q", ErrOutsideBrain, path)
}
// 5. Containment again, after resolving symlinks. The tree is Syncthing
// managed, so a link can appear without anyone adding one on purpose;
// a symlinked note or directory would otherwise read and write outside
// the brain entirely.
if err := assertNoSymlinkEscape(brainDir, abs); err != nil {
return Target{}, err
}
return Target{Rel: clean, Abs: abs}, nil
}
// allow enforces the scope's allowlist. The two scopes are DISJOINT, not
// nested: the privileged root scope reaches governance files and nothing else,
// so the tool that carries the higher privilege carries the smaller reach.
func allow(clean string, scope Scope) error {
if scope == ScopeRoot {
if !rootFiles[clean] {
return fmt.Errorf("%w: %q is not a governance file", ErrNotAllowed, clean)
}
return nil
}
if !isNotePath(clean) {
return fmt.Errorf("%w: %q is outside knowledge/ and wiki/", ErrNotAllowed, clean)
}
return nil
}
func isNotePath(clean string) bool {
var inDir bool
for _, d := range noteDirs {
if strings.HasPrefix(clean, d) {
inDir = true
break
}
}
if !inDir || filepath.Ext(clean) != ".md" {
return false
}
// _index.md is generated per wing, and brain-sync's conflict resolver
// deliberately resolves it in origin's favour — an edit here is reverted
// by design, so accepting one would be a lie.
return filepath.Base(clean) != "_index.md"
}
func within(root, abs string) bool {
rel, err := filepath.Rel(root, abs)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// assertNoSymlinkEscape resolves the deepest existing ancestor of abs and
// requires it to still be inside brainDir. The file itself need not exist
// (a create is legal); its directory chain must be real and contained.
func assertNoSymlinkEscape(brainDir, abs string) error {
if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %q is a symlink", ErrNotAllowed, abs)
}
realRoot, err := filepath.EvalSymlinks(brainDir)
if err != nil {
return fmt.Errorf("notes: resolve brain dir: %w", err)
}
dir := filepath.Dir(abs)
for {
resolved, err := filepath.EvalSymlinks(dir)
if err == nil {
if !within(realRoot, resolved) {
return fmt.Errorf("%w: %q resolves outside the brain dir", ErrOutsideBrain, dir)
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("notes: resolve %q: %w", dir, err)
}
parent := filepath.Dir(dir)
if parent == dir {
return fmt.Errorf("%w: no existing ancestor for %q", ErrOutsideBrain, abs)
}
dir = parent
}
}
// assertSyncSource refuses a brain dir that is not a git working tree.
//
// brain#2 was an edit landing on a directory the sync does not commit from.
// That cannot be prevented by review — a wrong INGEST_BRAIN_DIR in a future
// manifest reintroduces it while every unit test still passes — so it is
// asserted at runtime, the same check brain-sync itself makes before running.
func assertSyncSource(brainDir string) error {
if fi, err := os.Stat(filepath.Join(brainDir, ".git")); err != nil || !fi.IsDir() {
return fmt.Errorf("%w: %q has no .git, so an edit there would never be committed",
ErrNotSyncSource, brainDir)
}
return nil
}