hyperguild#123, the audit follow-up from #111: writeHallNote and UpdateNote both validate the claim envelope (half_life/applies/ claim_provenance) before writing; brain_edit didn't -- a byte-level string replace has no frontmatter awareness at all, so a caller could rewrite an existing envelope line (e.g. flip claim_provenance from asserted to guessed, or set half_life to a value outside the enum) straight past the validator. This was the one live, model-facing gap #123 identified; the ingestion pipeline write paths audited alongside it turned out not to touch these fields at all, so this closes the issue. Fixed by previewing what old_str->new_str would produce -- the exact strings.Replace(before, old_str, new_str, 1) formula notes.Edit itself uses internally -- and validating the envelope on that preview before the real edit is allowed to run. Fail-closed: an invalid result blocks the edit entirely, matching create/supersede's existing posture. Scoped to notes only (scope == notes.ScopeNotes) since governance files have no claim-envelope concept, and gated on a cheap substring check so an ordinary body edit that never mentions an envelope field name pays nothing extra. The preview doesn't replicate notes.Edit's uniqueness/CRLF/conflict- marker checks -- if old_str is absent or ambiguous, the preview may not match what Edit ultimately does, but Edit independently and correctly rejects those cases at write time regardless, so nothing about write safety rides on this preview's accuracy, only the envelope check itself. frontmatterScalars is a third, minimal implementation of the same indented-line-safe scalar extraction as the sibling packages (hyperguild#126/#128) -- deliberately not shared/exported across packages (each package's frontmatter handling has slightly different needs and none currently import each other), but built the same way from the start this time rather than needing a follow-up fix. Flip-tested: disabling the pre-check gate lets the invalid-envelope test fail (the edit silently succeeds); restoring it passes. Full module suite green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
311 lines
13 KiB
Go
311 lines
13 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
|
)
|
|
|
|
// noteToolDescriptors are the model-facing halves of brain#4.
|
|
//
|
|
// brain_edit_root is deliberately absent: it dispatches but is not listed, the
|
|
// same de-surfacing ADR-0012 rule 4 applies to brain_index (#78). Hiding the
|
|
// verb matters here for a specific reason — brain content is ingested from the
|
|
// outside world and reaches a model's context, so a listed governance-file
|
|
// editor is a durable prompt-injection path: one poisoned page could rewrite
|
|
// the CLAUDE.md that a .dispatch-allow agent later obeys. A human who knows
|
|
// the tool exists can still call it; a model reading tools/list cannot
|
|
// discover it.
|
|
func noteToolDescriptors() []map[string]any {
|
|
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
|
schema := func(required []string, props map[string]any) json.RawMessage {
|
|
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
|
return b
|
|
}
|
|
return []map[string]any{
|
|
{
|
|
"name": "brain_read",
|
|
"description": "Return a note's raw markdown verbatim — no synthesis, no excerpting. " +
|
|
"Use this to obtain the exact text before brain_edit; never build old_str from a " +
|
|
"brain_query excerpt, which is truncated and normalised and will not match.",
|
|
"inputSchema": schema([]string{"path"}, map[string]any{
|
|
"path": str("brain-relative path under knowledge/ or wiki/, e.g. wiki/homelab/facts/x.md"),
|
|
}),
|
|
},
|
|
{
|
|
"name": "brain_edit",
|
|
"description": "Replace exactly one occurrence of old_str with new_str in a note, leaving " +
|
|
"every other byte identical. Fails loudly if old_str is absent or matches more than " +
|
|
"once — extend old_str until it is unique. Does NOT stamp updated_at or supersedes " +
|
|
"(brain_update does); it is a byte-level patch, not a metadata operation.",
|
|
"inputSchema": schema([]string{"path", "old_str", "new_str"}, map[string]any{
|
|
"path": str("brain-relative path under knowledge/ or wiki/"),
|
|
"old_str": str("exact text to replace; must occur exactly once"),
|
|
"new_str": str("replacement text; empty string deletes old_str"),
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- hyperguild#97: governance-rewrite observability -----------------------
|
|
|
|
// govNotifier raises an out-of-band alert. Declared here rather than importing
|
|
// internal/audit so the MCP package keeps depending on capture alone.
|
|
type govNotifier interface {
|
|
Notify(ctx context.Context, msg string) error
|
|
}
|
|
|
|
// WithGovernanceObserver attaches the audit sink and notifier that observe a
|
|
// successful brain_edit_root. Both are optional; either may be nil.
|
|
func (s *Server) WithGovernanceObserver(sink capture.AuditSink, n govNotifier) {
|
|
s.govAudit, s.govNotify = sink, n
|
|
}
|
|
|
|
type brainReadArgs struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
func (s *Server) brainRead(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
var a brainReadArgs
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return nil, fmt.Errorf("parse args: %w", err)
|
|
}
|
|
content, err := notes.Read(s.brainDir, a.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": a.Path, "content": string(content)})
|
|
}
|
|
|
|
type brainEditArgs struct {
|
|
Path string `json:"path"`
|
|
OldStr string `json:"old_str"`
|
|
NewStr string `json:"new_str"`
|
|
}
|
|
|
|
// brainEdit is the model-facing editor: notes only.
|
|
//
|
|
// Deliberately NOT audited (#97). A note edit is already fully reconstructible
|
|
// from git history, which carries the same before/after bytes plus a diff.
|
|
// Governance files get the extra trail because they change what later agents
|
|
// obey — the asymmetry is about instruction-carrying content, not about how
|
|
// hard the change is to reconstruct. Auditing every note edit would also bury
|
|
// the rare governance record under routine traffic, which is how an alert
|
|
// stops being read.
|
|
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
_, out, err := s.editWithScope(args, notes.ScopeNotes)
|
|
return out, err
|
|
}
|
|
|
|
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
|
// the authenticated principal: only the static local-CLI token may call it, so
|
|
// a claude.ai-origin session — the one that may have just read an untrusted
|
|
// page — cannot rewrite the instructions later agents load.
|
|
func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
info, ok := ctx.Value(principalKey).(principalInfo)
|
|
if !ok || !info.viaStatic {
|
|
return nil, fmt.Errorf("brain_edit_root requires the local static principal; " +
|
|
"a governance file is not editable from a model-facing origin")
|
|
}
|
|
res, out, err := s.editWithScope(args, notes.ScopeRoot)
|
|
if err != nil {
|
|
// A refusal is not a rewrite. Recording or alerting on one would cry
|
|
// wolf, and the gate already logs its own denials.
|
|
return out, err
|
|
}
|
|
// A durable instruction rewrite should be loud, not silent.
|
|
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
|
|
s.observeGovernanceEdit(ctx, info.principal, res)
|
|
return out, err
|
|
}
|
|
|
|
// observeGovernanceEdit records and alerts on a rewrite that already landed.
|
|
//
|
|
// Best-effort by construction: the bytes are written and brain-sync will commit
|
|
// them within the quarter hour, so failing the call here would report a
|
|
// non-rewrite to the caller while the rewrite sits on disk. A failure to
|
|
// observe is therefore logged at ERROR — the one case where the trail itself is
|
|
// what broke, and the only remaining signal.
|
|
func (s *Server) observeGovernanceEdit(ctx context.Context, principal string, res notes.Result) {
|
|
// Path and hashes only. The notification leaves the cluster for a phone
|
|
// via a third-party topic; a hash identifies the rewrite, the bytes would
|
|
// leak whatever was written into an instruction file.
|
|
msg := fmt.Sprintf("brain_edit_root applied\npath=%s\nprincipal=%s\nbefore=%s\nafter=%s",
|
|
res.Path, principal, res.PrevContentHash, res.ContentHash)
|
|
|
|
if s.govNotify != nil {
|
|
if err := s.govNotify.Notify(ctx, msg); err != nil {
|
|
slog.Error("brain_edit_root notify failed", "path", res.Path, "err", err)
|
|
}
|
|
}
|
|
if s.govAudit == nil {
|
|
return
|
|
}
|
|
// Internal, not Confidential: a governance file is not client data, and
|
|
// Confidential would make DegradingSink REFUSE to record while loki is
|
|
// down rather than buffer — losing exactly the record that matters most.
|
|
outcome, err := s.govAudit.Reserve(ctx, classification.Internal)
|
|
if err != nil {
|
|
slog.Error("brain_edit_root audit reserve failed", "path", res.Path, "err", err)
|
|
return
|
|
}
|
|
entry := capture.AuditEntry{
|
|
Timestamp: time.Now().UTC(),
|
|
Principal: principal,
|
|
Actor: "brain_edit_root",
|
|
Harness: "mcp",
|
|
EffectiveClassification: classification.Internal.String(),
|
|
Items: []string{res.Path},
|
|
SecurityEvents: []string{fmt.Sprintf(
|
|
"governance file rewritten: %s before=%s after=%s",
|
|
res.Path, res.PrevContentHash, res.ContentHash)},
|
|
}
|
|
if err := s.govAudit.Record(ctx, entry, outcome); err != nil {
|
|
slog.Error("brain_edit_root audit record failed", "path", res.Path, "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.Result, json.RawMessage, error) {
|
|
var a brainEditArgs
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
|
}
|
|
|
|
// hyperguild#123: brain_write/brain_update both validate the claim
|
|
// envelope before writing (hyperguild#111: "same rules when a note
|
|
// changes as when it's born"); brain_edit didn't — a byte-level string
|
|
// replace has no frontmatter awareness at all, so it could rewrite an
|
|
// existing half_life:/applies:/claim_provenance: line straight past the
|
|
// validator entirely. Scoped to notes only (governance files have no
|
|
// claim-envelope concept) and gated on a cheap substring check so an
|
|
// edit that never touches these field names pays nothing extra.
|
|
if scope == notes.ScopeNotes && touchesClaimEnvelope(a.OldStr, a.NewStr) {
|
|
if verr := validateEnvelopeAfterEdit(s.brainDir, a.Path, a.OldStr, a.NewStr); verr != nil {
|
|
return notes.Result{}, nil, fmt.Errorf("claim envelope: %w", verr)
|
|
}
|
|
}
|
|
|
|
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
|
if err != nil {
|
|
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
|
|
// hyperguild#115: "old_str is empty" alone doesn't say whether the
|
|
// caller sent it empty or a hop between the caller and here lost
|
|
// it. Naming the raw argument byte count makes that distinguishable
|
|
// without a second investigation.
|
|
return notes.Result{}, nil, fmt.Errorf("%w (raw arguments were %d bytes)", err, len(args))
|
|
}
|
|
return notes.Result{}, nil, err
|
|
}
|
|
|
|
// Reindex only when the edit could have changed what the index reads.
|
|
// BuildWingIndex consumes title/created_at from the frontmatter block;
|
|
// BM25 walks the filesystem live and embeddings are mtime-driven, so an
|
|
// unconditional rebuild would buy nothing and dirty a second file —
|
|
// _index.md being precisely the class brain-sync already needs a bespoke
|
|
// conflict resolver for.
|
|
if wing := wingOf(res.Path); wing != "" && touchesFrontmatter(a.OldStr, a.NewStr) {
|
|
if ierr := brain.BuildWingIndex(s.brainDir, wing); ierr != nil {
|
|
// Best-effort, matching every other index hook here: the write
|
|
// already landed and will be committed, so failing the call would
|
|
// make the caller retry an edit whose old_str no longer exists.
|
|
res.ReindexError = ierr.Error()
|
|
}
|
|
}
|
|
out, merr := json.Marshal(res)
|
|
return res, out, merr
|
|
}
|
|
|
|
// wingOf returns the wing for a wiki path, or "" for knowledge/ and root.
|
|
func wingOf(rel string) string {
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) < 3 || parts[0] != "wiki" {
|
|
return ""
|
|
}
|
|
return parts[1]
|
|
}
|
|
|
|
// touchesFrontmatter reports whether either side of the edit mentions a field
|
|
// the wing index reads.
|
|
func touchesFrontmatter(oldStr, newStr string) bool {
|
|
for _, s := range []string{oldStr, newStr} {
|
|
if strings.Contains(s, "title:") || strings.Contains(s, "created_at:") || strings.Contains(s, "---") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// touchesClaimEnvelope reports whether oldStr/newStr mention any of the
|
|
// three claim-envelope field names — the cheap gate that lets an ordinary
|
|
// body edit skip the read-and-validate round trip entirely.
|
|
func touchesClaimEnvelope(oldStr, newStr string) bool {
|
|
for _, s := range []string{oldStr, newStr} {
|
|
if strings.Contains(s, claim.FieldHalfLife) || strings.Contains(s, claim.FieldApplies) || strings.Contains(s, claim.FieldProvenance) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validateEnvelopeAfterEdit previews what oldStr->newStr would produce —
|
|
// the exact strings.Replace(before, oldStr, newStr, 1) formula notes.Edit
|
|
// itself uses internally — and validates the resulting frontmatter's claim
|
|
// envelope before the real edit is allowed to run. Fail-closed: an invalid
|
|
// result blocks the edit entirely, the same posture writeHallNote/UpdateNote
|
|
// already apply on create/supersede.
|
|
//
|
|
// This does not replicate notes.Edit's uniqueness/CRLF/conflict-marker
|
|
// checks — if old_str is absent or ambiguous, this preview may not match
|
|
// what Edit ultimately does, but Edit independently and correctly rejects
|
|
// those cases at write time regardless of what this function decides, so
|
|
// the only thing riding on this preview's accuracy is the envelope check
|
|
// itself, not write safety.
|
|
func validateEnvelopeAfterEdit(brainDir, path, oldStr, newStr string) error {
|
|
before, err := notes.Read(brainDir, path)
|
|
if err != nil {
|
|
// Let notes.Edit itself produce the real error (missing file, etc.)
|
|
// — this function's job is the envelope, not read failures.
|
|
return nil
|
|
}
|
|
after := strings.Replace(string(before), oldStr, newStr, 1)
|
|
return claim.ValidateEnvelope(frontmatterScalars(after))
|
|
}
|
|
|
|
// frontmatterScalars extracts top-level "key: value" scalar pairs from
|
|
// doc's leading fence block, for the claim-envelope check only — not a
|
|
// general frontmatter parser. Indented lines (YAML block-list items,
|
|
// wrapped values) are skipped rather than misparsed as keys, same fix as
|
|
// hyperguild#126/#128 in the sibling packages this mirrors.
|
|
func frontmatterScalars(doc string) map[string]string {
|
|
out := map[string]string{}
|
|
if !strings.HasPrefix(doc, "---\n") {
|
|
return out
|
|
}
|
|
rest := doc[len("---\n"):]
|
|
end := strings.Index(rest, "\n---\n")
|
|
if end < 0 {
|
|
return out
|
|
}
|
|
for _, line := range strings.Split(rest[:end], "\n") {
|
|
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
|
continue
|
|
}
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
|
|
}
|
|
return out
|
|
}
|