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:
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||
@@ -97,6 +98,8 @@ func (s *Server) tools() []map[string]any {
|
||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||
}),
|
||||
},
|
||||
noteToolDescriptors()[0],
|
||||
noteToolDescriptors()[1],
|
||||
{
|
||||
"name": "brain_tunnel",
|
||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||
@@ -320,6 +323,15 @@ func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMe
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("id or path is required")
|
||||
}
|
||||
// Containment: the store resolves any brain-relative path, and
|
||||
// parseFrontmatter returns the WHOLE file as body when there is no
|
||||
// frontmatter fence — so without this, brain_get reads verbatim session
|
||||
// transcripts under archive/ and sessions/, which BM25 never indexes and
|
||||
// which nobody therefore thinks of as reachable. Same allowlist as
|
||||
// brain_read (brain#4).
|
||||
if _, err := notes.Resolve(s.brainDir, target, notes.ScopeNotes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
note, err := s.store.Get(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user