// 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 }