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
277 lines
8.7 KiB
Go
277 lines
8.7 KiB
Go
package notes
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// maxNoteBytes caps what Read will hand back. Generous for knowledge/ and
|
|
// wiki/ (the largest note today is far under it) and small enough that a
|
|
// pathological file cannot flood a caller's context.
|
|
const maxNoteBytes = 256 << 10
|
|
|
|
// Errors callers distinguish.
|
|
var (
|
|
ErrNoMatch = errors.New("notes: old_str not found")
|
|
ErrAmbiguous = errors.New("notes: old_str matches more than once")
|
|
ErrNotUTF8 = errors.New("notes: content is not valid UTF-8")
|
|
ErrGitBusy = errors.New("notes: a git operation is in progress")
|
|
ErrTooLarge = errors.New("notes: file exceeds the readable size limit")
|
|
ErrDegenerate = errors.New("notes: edit is degenerate")
|
|
ErrInConflict = errors.New("notes: file contains unresolved conflict markers")
|
|
)
|
|
|
|
// pathLocks serialises edits per file within this process. The flock below
|
|
// covers the cross-process case (two pods share the hostPath during a rolling
|
|
// update, and brain-fix/brain-enrich write the same tree).
|
|
var pathLocks sync.Map // string -> *sync.Mutex
|
|
|
|
// Result is what a successful edit reports.
|
|
type Result struct {
|
|
Path string `json:"path"`
|
|
ContentHash string `json:"content_hash"`
|
|
BytesBefore int `json:"bytes_before"`
|
|
BytesAfter int `json:"bytes_after"`
|
|
ReindexError string `json:"reindex_error,omitempty"`
|
|
}
|
|
|
|
// Read returns a note's bytes verbatim.
|
|
//
|
|
// No frontmatter parsing, no excerpting, no normalisation: the entire purpose
|
|
// is to give a caller an exact basis for an edit. An excerpt from brain_query
|
|
// would be truncated and normalised, and every old_str built from one would
|
|
// miss.
|
|
func Read(brainDir, path string) ([]byte, error) {
|
|
t, err := Resolve(brainDir, path, ScopeNotes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return readContained(t)
|
|
}
|
|
|
|
// ReadScoped is Read for a caller already holding the privileged scope.
|
|
func ReadScoped(brainDir, path string, scope Scope) ([]byte, error) {
|
|
t, err := Resolve(brainDir, path, scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return readContained(t)
|
|
}
|
|
|
|
func readContained(t Target) ([]byte, error) {
|
|
fi, err := os.Stat(t.Abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("notes: stat %s: %w", t.Rel, err)
|
|
}
|
|
if fi.Size() > maxNoteBytes {
|
|
return nil, fmt.Errorf("%w: %s is %d bytes (limit %d)", ErrTooLarge, t.Rel, fi.Size(), maxNoteBytes)
|
|
}
|
|
b, err := os.ReadFile(t.Abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("notes: read %s: %w", t.Rel, err)
|
|
}
|
|
// The MCP response marshals this into a JSON string, and encoding/json
|
|
// substitutes U+FFFD for invalid UTF-8 — silently returning different
|
|
// bytes than the file holds, which defeats "verbatim" entirely.
|
|
if !utf8.Valid(b) {
|
|
return nil, fmt.Errorf("%w: %s", ErrNotUTF8, t.Rel)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Edit replaces exactly one occurrence of oldStr with newStr, in place.
|
|
//
|
|
// Fails loudly and writes nothing on: zero matches, more than one match
|
|
// (counted with overlap), degenerate arguments, invalid UTF-8, a git operation
|
|
// in flight, or unresolved conflict markers in the target.
|
|
func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
|
|
t, err := Resolve(brainDir, path, scope)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if oldStr == "" {
|
|
return Result{}, fmt.Errorf("%w: old_str is empty", ErrDegenerate)
|
|
}
|
|
if oldStr == newStr {
|
|
return Result{}, fmt.Errorf("%w: old_str equals new_str", ErrDegenerate)
|
|
}
|
|
if !utf8.ValidString(newStr) {
|
|
return Result{}, fmt.Errorf("%w: new_str", ErrNotUTF8)
|
|
}
|
|
// brain#2 guard: refuse to write to a tree the sync does not commit from.
|
|
if err := assertSyncSource(brainDir); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := assertGitIdle(brainDir); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
mu := lockFor(t.Abs)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
unlock, err := flockFile(t.Abs)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
defer unlock()
|
|
|
|
// Read INSIDE the lock: the match count must be evaluated against the
|
|
// bytes we are about to overwrite, or a concurrent edit is silently lost
|
|
// while both callers are told they succeeded.
|
|
before, err := readContained(t)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if hasConflictMarkers(before) {
|
|
return Result{}, fmt.Errorf("%w: %s", ErrInConflict, t.Rel)
|
|
}
|
|
|
|
switch n := countOverlapping(string(before), oldStr); {
|
|
case n == 0:
|
|
return Result{}, fmt.Errorf("%w in %s (0 matches)%s", ErrNoMatch, t.Rel, crlfHint(string(before), oldStr))
|
|
case n > 1:
|
|
return Result{}, fmt.Errorf("%w in %s (%d matches) — extend old_str until it is unique",
|
|
ErrAmbiguous, t.Rel, n)
|
|
}
|
|
|
|
after := strings.Replace(string(before), oldStr, newStr, 1)
|
|
if err := writeAtomic(t.Abs, []byte(after)); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte(after))
|
|
return Result{
|
|
Path: t.Rel,
|
|
ContentHash: hex.EncodeToString(sum[:]),
|
|
BytesBefore: len(before),
|
|
BytesAfter: len(after),
|
|
}, nil
|
|
}
|
|
|
|
// countOverlapping counts occurrences allowing overlap. bytes.Count does not:
|
|
// "aa" occurs once in "aaa" by its reckoning, so a Count-based uniqueness
|
|
// check accepts an ambiguous edit as unique.
|
|
func countOverlapping(s, sub string) int {
|
|
var n, from int
|
|
for {
|
|
i := strings.Index(s[from:], sub)
|
|
if i < 0 {
|
|
return n
|
|
}
|
|
n++
|
|
from += i + 1
|
|
}
|
|
}
|
|
|
|
// crlfHint reports whether the caller's old_str would have matched if line
|
|
// endings agreed. It never normalises — that would break byte-identity — but
|
|
// an opaque "no match" is unfixable from the caller's side, and a stripped \r
|
|
// is the likeliest cause.
|
|
func crlfHint(content, oldStr string) string {
|
|
if strings.Contains(content, "\r\n") && !strings.Contains(oldStr, "\r\n") {
|
|
if strings.Contains(strings.ReplaceAll(content, "\r\n", "\n"), oldStr) {
|
|
return " — the file uses CRLF line endings and old_str does not; include the \\r"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hasConflictMarkers(b []byte) bool {
|
|
s := string(b)
|
|
return strings.Contains(s, "\n<<<<<<< ") || strings.HasPrefix(s, "<<<<<<< ")
|
|
}
|
|
|
|
// assertGitIdle refuses while brain-sync (or brain-fix, or a human) has a git
|
|
// operation in flight. An edit landing between `git merge` and its abort is
|
|
// either hard-reset away or committed as a wrongly resolved conflict — and the
|
|
// caller was told it succeeded.
|
|
func assertGitIdle(brainDir string) error {
|
|
for _, marker := range []string{"MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "index.lock"} {
|
|
if _, err := os.Stat(filepath.Join(brainDir, ".git", marker)); err == nil {
|
|
return fmt.Errorf("%w (.git/%s present) — retry after the sync settles", ErrGitBusy, marker)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lockFor(abs string) *sync.Mutex {
|
|
v, _ := pathLocks.LoadOrStore(abs, &sync.Mutex{})
|
|
return v.(*sync.Mutex)
|
|
}
|
|
|
|
// flockFile takes an advisory lock covering other processes sharing the
|
|
// hostPath — during a RollingUpdate two ingestion pods do.
|
|
func flockFile(abs string) (func(), error) {
|
|
f, err := os.OpenFile(abs+".lock", os.O_CREATE|os.O_RDWR, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("notes: lock %s: %w", filepath.Base(abs), err)
|
|
}
|
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("notes: flock %s: %w", filepath.Base(abs), err)
|
|
}
|
|
return func() {
|
|
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
_ = f.Close()
|
|
_ = os.Remove(abs + ".lock")
|
|
}, nil
|
|
}
|
|
|
|
// writeAtomic replaces the file via a same-directory temp + rename, then
|
|
// fsyncs the directory so the rename survives a node crash.
|
|
//
|
|
// Same directory because a rename across the hostPath boundary fails with
|
|
// EXDEV; the ".tmp" suffix because the tree's .gitignore already covers it, so
|
|
// a leaked temp is not committed by `git add -A`.
|
|
func writeAtomic(abs string, content []byte) error {
|
|
dir := filepath.Dir(abs)
|
|
tmp, err := os.CreateTemp(dir, filepath.Base(abs)+".*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("notes: temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
|
|
|
if _, err := tmp.Write(content); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("notes: write temp: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("notes: sync temp: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("notes: close temp: %w", err)
|
|
}
|
|
// CreateTemp makes 0600; every other writer of this tree uses 0644 and
|
|
// other uids read it.
|
|
if err := os.Chmod(tmpName, 0o644); err != nil {
|
|
return fmt.Errorf("notes: chmod temp: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, abs); err != nil {
|
|
return fmt.Errorf("notes: rename: %w", err)
|
|
}
|
|
return fsyncDir(dir)
|
|
}
|
|
|
|
func fsyncDir(dir string) error {
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("notes: open dir: %w", err)
|
|
}
|
|
defer func() { _ = d.Close() }()
|
|
if err := d.Sync(); err != nil {
|
|
return fmt.Errorf("notes: fsync dir: %w", err)
|
|
}
|
|
return nil
|
|
}
|