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>
202 lines
7.2 KiB
Go
202 lines
7.2 KiB
Go
// 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
|
|
}
|