feat(mcp): brain_read + brain_edit, contained to the sync source (brain#4) (#96)
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>
This commit was merged in pull request #96.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user