Files
hyperguild/ingestion/internal/notes/path.go
mathiasandClaude Opus 5.5 27a01dd2af
CI / Lint / Test / Vet (push) Successful in 46s
CI / Mirror to GitHub (push) Successful in 4s
fix(brain): context, brain_get and brain_read read through brainfs (#157, slice 2) (#165)
readExcerpt, ReadNote and ReadScoped now read through brainfs (os.Root, regular files only, type checked on the opened handle), so a symlink swapped in after a check is not followed and a FIFO does not hang the reader. ReadRegularMax bounds the read itself with io.LimitReader(max+1). notes.Target carries the brain root.

Refs #157. Adversarial review round 2: PASS (round 1 BLOCKED: unbounded read, rule-9 test names).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-04 05:59:55 +00:00

203 lines
7.3 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
Brain string // the brain root Rel is relative to
}
// 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, Brain: brainDir}, 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
}