fix(mcp): validate the claim envelope before brain_edit writes too #136

Merged
mathias merged 8 commits from fix/123-brain-edit-envelope-check into main 2026-09-23 10:28:08 +00:00
2 changed files with 63 additions and 26 deletions
Showing only changes of commit 062574dad8 - Show all commits
+38 -26
View File
@@ -187,10 +187,9 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
// 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 {
// claim-envelope concept).
if scope == notes.ScopeNotes {
if verr := checkClaimEnvelope(s.brainDir, a.Path, a.OldStr, a.NewStr); verr != nil {
return notes.Result{}, nil, fmt.Errorf("claim envelope: %w", verr)
}
}
@@ -245,42 +244,59 @@ func touchesFrontmatter(oldStr, newStr string) bool {
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
// checkClaimEnvelope previews what old_str->new_str 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.
//
// Gated on old_str appearing within the note's frontmatter BLOCK, not on
// old_str/new_str mentioning a claim-envelope field NAME — found in review:
// a field-name-substring gate misses a value-only edit like
// "asserted"->"guessed" on a claim_provenance line, which never mentions
// the field name at all. That is exactly the shape of edit a minimal,
// natural diff produces, so a field-name gate was closing the issue in
// name only. Checking block containment instead means any edit that
// couldn't possibly touch the frontmatter (its target text only exists in
// the body) is still skipped cheaply, while nothing inside the fence can
// slip through unvalidated regardless of which token it targets.
//
// 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 {
func checkClaimEnvelope(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
}
block, ok := frontmatterBlock(string(before))
if !ok || !strings.Contains(block, oldStr) {
return nil // old_str isn't in the frontmatter block at all
}
after := strings.Replace(string(before), oldStr, newStr, 1)
return claim.ValidateEnvelope(frontmatterScalars(after))
}
// frontmatterBlock returns the raw text between doc's opening and closing
// `---` fences, or ok=false if doc has no (properly closed) fence.
func frontmatterBlock(doc string) (block string, ok bool) {
if !strings.HasPrefix(doc, "---\n") {
return "", false
}
rest := doc[len("---\n"):]
end := strings.Index(rest, "\n---\n")
if end < 0 {
return "", false
}
return rest[:end], true
}
// 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,
@@ -288,15 +304,11 @@ func validateEnvelopeAfterEdit(brainDir, path, oldStr, newStr string) error {
// 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") {
block, ok := frontmatterBlock(doc)
if !ok {
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") {
for _, line := range strings.Split(block, "\n") {
if line != "" && (line[0] == ' ' || line[0] == '\t') {
continue
}
@@ -116,6 +116,31 @@ func TestBrainEdit_RejectsAnInvalidClaimEnvelopeWrittenViaStringReplace(t *testi
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_RejectsAValueOnlyEditThatBreaksTheEnvelope(t *testing.T) {
// Found in review: the original gate (touchesClaimEnvelope) checked
// whether old_str/new_str mention the FIELD NAME — but a minimal diff
// (the natural shape of any real edit, model-generated or human) can
// target just the VALUE token and never mention the field name at all.
// "asserted" -> "guessed" on a claim_provenance line walked straight
// past the field-name substring check and wrote an invalid enum value
// with no error — the exact bypass #123 was supposed to close, closed
// in name only.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_AllowsAValidClaimEnvelopeEdit(t *testing.T) {
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")