From f7fe00cecf5ec05e0ee43ea4d898d44258504542 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:13:52 +0200 Subject: [PATCH 1/8] fix(mcp): validate the claim envelope before brain_edit writes too 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 --- ingestion/internal/mcp/tools_notes.go | 80 ++++++++++++++++++++++ ingestion/internal/mcp/tools_notes_test.go | 72 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index 866ffde..df99825 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -179,6 +180,21 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R 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 == "" { @@ -228,3 +244,67 @@ 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 +// 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 +} diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index b936e75..dbe6fcc 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -93,6 +93,78 @@ func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) { assert.Contains(t, errMsg, "raw arguments were", "%v", errMsg) } +func TestBrainEdit_RejectsAnInvalidClaimEnvelopeWrittenViaStringReplace(t *testing.T) { + // hyperguild#123: brain_write/brain_update both validate the claim + // envelope (half_life/applies/claim_provenance) before writing — + // brain_edit didn't, even though it's the one live, model-facing path + // that can rewrite an existing envelope line directly via raw string + // replace, no parsing involved. "same rules when a note changes as when + // it's born" (hyperguild#111's own framing) applies here too. + 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": "claim_provenance: asserted", "new_str": "claim_provenance: 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") + 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": "half_life: durable", "new_str": "half_life: seasonal", + }) + require.NotContains(t, resp, "error", "%v", resp) + + got, err := os.ReadFile(note) + require.NoError(t, err) + assert.Contains(t, string(got), "half_life: seasonal") +} + +func TestBrainEdit_UnrelatedEditsSkipTheEnvelopeCheckEntirely(t *testing.T) { + // The gate only engages when old_str/new_str actually mention an + // envelope field name — an ordinary body edit on a note that happens to + // carry a valid envelope must not pay for (or be affected by) a check + // that has nothing to do with it. + 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\nold body\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": "old body", "new_str": "new body", + }) + require.NotContains(t, resp, "error", "%v", resp) + + got, err := os.ReadFile(note) + require.NoError(t, err) + assert.Contains(t, string(got), "new body") +} + +func TestBrainEditRoot_GovernanceFilesAreNeverEnvelopeChecked(t *testing.T) { + // CLAUDE.md has no claim-envelope concept at all — the check must be + // scoped to notes (ScopeNotes), never applied on the governance path. + srv, _, _, _ := govServer(t) + + resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{ + "path": "CLAUDE.md", "old_str": "# rules", "new_str": "half_life: durable", + }) + require.NotContains(t, resp, "error", "%v", resp) +} + func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) { dir := newBrainRepo(t) srv := mcp.NewServer(dir, nil, nil, nil) -- 2.54.0 From 062574dad8e12fcc879c7731c96f027c38b8461b Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:21:53 +0200 Subject: [PATCH 2/8] fix(mcp): close claim-envelope bypass on value-only brain_edit diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of PR #136 found the gate trivially bypassable: touchesClaimEnvelope only fired when old_str/new_str contained the claim-envelope FIELD NAME as a substring, but a natural value-only diff (old_str: "asserted", new_str: "guessed" against a claim_provenance line) never mentions the field name and walked straight past it — notes.Edit wrote the invalid enum value to disk with zero error. Redesign the gate to be position-based instead of name-based: checkClaimEnvelope now checks whether old_str is physically contained within the note's frontmatter block (via the new frontmatterBlock helper), previews the exact strings.Replace the real edit would perform, and validates the resulting envelope before the edit is allowed to run. frontmatterScalars now builds on the shared frontmatterBlock helper. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 64 +++++++++++++--------- ingestion/internal/mcp/tools_notes_test.go | 25 +++++++++ 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index df99825..b2c5269 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -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 } diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index dbe6fcc..5989cde 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -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") -- 2.54.0 From 0f932d8f774d62562369fcf0a59f9244c3dc2212 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:27:30 +0200 Subject: [PATCH 3/8] fix(mcp): drop the containment gate, always validate the post-edit envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review found a third bypass: checkClaimEnvelope required old_str to be fully contained within the extracted frontmatter block (which excludes both "---" fence lines). An old_str disambiguated across the closing fence into the body — a natural choice, since notes.Edit requires old_str to be unique — failed that containment test, so the gate skipped validation entirely while notes.Edit applied the replace verbatim, reaching the same invalid-enum outcome as the two bypasses already closed. Remove the containment gate structurally instead of patching another edge: checkClaimEnvelope now always computes the post-edit doc and validates its resulting frontmatter, regardless of old_str's textual relationship to the fence. A body-only edit leaves the frontmatter unchanged (still valid, since the note started valid), so this is a no-op for the common case and fail-closed for every case that touches the envelope, however old_str is phrased. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 13 +++++++---- ingestion/internal/mcp/tools_notes_test.go | 27 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index b2c5269..72ad383 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -275,10 +275,15 @@ func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error { // — 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 - } + // No gate on where old_str falls relative to the frontmatter fence: + // round 2 found that requiring old_str to be fully contained in the + // extracted block was itself bypassable by an old_str disambiguated + // across the closing "---" into the body. Simpler and bypass-proof: + // always compute what the edit would produce and validate that + // resulting state's envelope. A body-only edit leaves the frontmatter + // (and thus the validation outcome) unchanged, so this is a no-op for + // the common case and fail-closed for every case that touches the + // envelope, regardless of how old_str is phrased. after := strings.Replace(string(before), oldStr, newStr, 1) return claim.ValidateEnvelope(frontmatterScalars(after)) } diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index 5989cde..b32ae14 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -141,6 +141,33 @@ func TestBrainEdit_RejectsAValueOnlyEditThatBreaksTheEnvelope(t *testing.T) { assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical") } +func TestBrainEdit_RejectsAnOldStrThatCrossesTheClosingFence(t *testing.T) { + // Found in round-2 review: the position-based gate required old_str to + // be fully CONTAINED in the extracted frontmatter block (which excludes + // both "---" fence lines). An old_str disambiguated by extending past + // the closing fence into the body — a natural choice, since notes.Edit + // requires old_str to be unique in the file — fails that containment + // test, so checkClaimEnvelope skips validation entirely while + // notes.Edit still applies the replace verbatim. Same invalid-enum + // outcome as the original two bypasses, reached a third way. + 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\n---\n\nbody", + "new_str": "guessed\n---\n\nbody", + }) + 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") -- 2.54.0 From 110e8b8f75154a61afd478a792115b5a3f971a4b Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:32:46 +0200 Subject: [PATCH 4/8] fix(mcp): reject edits that strip a note's frontmatter fence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review found a fourth bypass: checkClaimEnvelope always validates frontmatterScalars(after), but an edit whose new_str drops the closing "---" degrades `after` to "unfenced" in the same call. frontmatterBlock(after) then returns ok=false, frontmatterScalars returns an empty map, and claim.ValidateEnvelope({}) is nil by design (absent fields are valid) — so an invalid enum value lands on disk with zero error, and the note is left permanently unfenced. A note that had a valid fence before the edit must still have one after, or the edit is rejected outright — closes this off structurally rather than special-casing the fence-removal shape. Also guards oldStr == "" as a no-op (strings.Replace treats "" as matching position 0, which would otherwise look like an edit that inserts text before the fence): that degenerate case belongs to notes.Edit's own "old_str is empty" refusal (#115), not to the envelope check — TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes caught the regression when this guard was still missing. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 19 +++++++++++++++ ingestion/internal/mcp/tools_notes_test.go | 28 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index 72ad383..1a743a8 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -269,6 +269,14 @@ func touchesFrontmatter(oldStr, newStr string) bool { // the only thing riding on this preview's accuracy is the envelope check // itself, not write safety. func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error { + if oldStr == "" { + // hyperguild#115: strings.Replace treats "" as matching position 0, + // which would make an empty old_str look like an edit that inserts + // text before the fence — a degenerate-input case that belongs to + // notes.Edit's own "old_str is empty" refusal, not to the envelope + // check. + return nil + } before, err := notes.Read(brainDir, path) if err != nil { // Let notes.Edit itself produce the real error (missing file, etc.) @@ -284,7 +292,18 @@ func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error { // (and thus the validation outcome) unchanged, so this is a no-op for // the common case and fail-closed for every case that touches the // envelope, regardless of how old_str is phrased. + _, hadFence := frontmatterBlock(string(before)) after := strings.Replace(string(before), oldStr, newStr, 1) + _, hasFence := frontmatterBlock(after) + if hadFence && !hasFence { + // Round 3: an edit that drops the closing fence degrades `after` + // to "unfenced" — frontmatterScalars then sees an empty map and + // ValidateEnvelope({}) is nil by design (absent fields are valid), + // so an invalid value would otherwise land with zero error while + // also corrupting the note's structure. A previously-fenced note + // must stay fenced, full stop. + return fmt.Errorf("edit would remove the frontmatter fence") + } return claim.ValidateEnvelope(frontmatterScalars(after)) } diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index b32ae14..c2421a5 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -168,6 +168,34 @@ func TestBrainEdit_RejectsAnOldStrThatCrossesTheClosingFence(t *testing.T) { assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical") } +func TestBrainEdit_RejectsAnEditThatRemovesTheClosingFence(t *testing.T) { + // Found in round-3 review: checkClaimEnvelope always validates the + // resulting frontmatter of `after` — but an edit whose new_str drops + // the closing "---" degrades `after` to "unfenced" in the same call. + // frontmatterBlock(after) then returns ok=false, frontmatterScalars + // returns an empty map, and claim.ValidateEnvelope({}) is nil by + // design (absent fields are valid) — so an invalid enum value lands on + // disk with zero error, and the note is left permanently unfenced. + // Structural fix: a note that had a valid fence before the edit must + // still have one after, or the edit is rejected outright. + 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": "claim_provenance: asserted\n---\n", + "new_str": "claim_provenance: not-a-real-value\n", + }) + require.Contains(t, resp, "error", "%v", resp) + + got, err := os.ReadFile(note) + require.NoError(t, err) + assert.Contains(t, string(got), "claim_provenance: asserted\n---\n", "rejected edit must leave the note byte-identical, fence intact") +} + func TestBrainEdit_AllowsAValidClaimEnvelopeEdit(t *testing.T) { dir := newBrainRepo(t) note := filepath.Join(dir, "wiki/homelab/facts/n.md") -- 2.54.0 From 4f75279ceb3a70e947776cffceea8a727daccdb6 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:40:00 +0200 Subject: [PATCH 5/8] fix(mcp): normalize CRLF before frontmatter fence detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review found a fifth bypass, and this one needs no adversarial old_str/new_str crafting at all: frontmatterBlock's fence detection matches the literal byte sequence "---\n". A note whose fence lines use CRLF ("---\r\n") fails strings.HasPrefix(doc, "---\n") on the precondition read purely from line-ending encoding — hadFence is false, so the round-3/round-4 checks can never fire, frontmatterScalars falls back to an empty map, and claim.ValidateEnvelope({}) is nil by design. An invalid claim_provenance value lands with zero error. Zero CRLF-fenced notes exist in the live corpus today, but nothing in the code prevented one, and this class of encoding-dependent false negative could resurface in any of the other fence-based checks. Normalize once inside frontmatterBlock so every caller (fence-presence detection and frontmatterScalars) agrees regardless of line ending. The broader structural concern — three independent, subtly inconsistent fence-parsing implementations across internal/mcp, internal/api, and internal/frontmatter, of which this CRLF gap is a direct symptom — is out of scope for this PR and filed separately as hyperguild#138. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 7 ++++++ ingestion/internal/mcp/tools_notes_test.go | 27 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index 1a743a8..043a217 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -310,6 +310,13 @@ func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error { // 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) { + // Round 4 found a note with CRLF fence lines ("---\r\n") silently + // defeats HasPrefix(doc, "---\n") on the precondition read — not an + // adversarial old_str/new_str, just an encoding difference — which + // makes checkClaimEnvelope no-op entirely. Normalize once, here, so + // every caller (hadFence/hasFence detection and frontmatterScalars) + // agrees regardless of line ending. + doc = strings.ReplaceAll(doc, "\r\n", "\n") if !strings.HasPrefix(doc, "---\n") { return "", false } diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index c2421a5..d991bf2 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -196,6 +196,33 @@ func TestBrainEdit_RejectsAnEditThatRemovesTheClosingFence(t *testing.T) { assert.Contains(t, string(got), "claim_provenance: asserted\n---\n", "rejected edit must leave the note byte-identical, fence intact") } +func TestBrainEdit_RejectsAnInvalidEnvelopeOnACRLFFencedNote(t *testing.T) { + // Found in round-4 review: frontmatterBlock's fence detection matches + // the literal byte sequence "---\n" / "\n---\n". A note whose fence + // lines use CRLF ("---\r\n") fails strings.HasPrefix(doc, "---\n") on + // the BEFORE read, so hadFence is false — not because the fence is + // missing, but because \r sits in front of every \n. hadFence&&!hasFence + // can then never fire, frontmatterScalars falls back to an empty map, + // and claim.ValidateEnvelope({}) is nil by design. No adversarial + // old_str/new_str crafting needed: encoding alone silently disables the + // whole envelope check for this note. Zero CRLF-fenced notes exist in + // the live corpus today, but nothing in the code prevents one. + dir := newBrainRepo(t) + note := filepath.Join(dir, "wiki/homelab/facts/n.md") + require.NoError(t, os.WriteFile(note, + []byte("---\r\ntitle: 'n'\r\nhalf_life: durable\r\napplies: universal\r\nclaim_provenance: asserted\r\n---\r\n\r\nbody\r\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") -- 2.54.0 From 1d67e16c0dd39e74af5e6f2148a97e266dd29211 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:44:54 +0200 Subject: [PATCH 6/8] fix(mcp): strip a leading UTF-8 BOM before frontmatter fence detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review found a sixth bypass, same mechanism as round 4's CRLF gap, one layer earlier: a leading UTF-8 BOM (EF BB BF) defeats strings.HasPrefix(doc, "---\n") before CRLF normalization even applies. Judged live-plausible rather than exotic — Notepad's default "UTF-8" save and PowerShell's pre-v6 Out-File/Set-Content both emit a BOM — so fixed inline at the same cost as the CRLF fix rather than deferred wholesale to hyperguild#138. Strip the BOM before the CRLF normalization in frontmatterBlock, so every caller (fence-presence detection and frontmatterScalars) agrees regardless of encoding. Drawing the line here: further encoding variants in this family (NEL line separators, UTF-16 BOM, lone-CR classic-Mac endings) are judged not live-plausible in a git-tracked UTF-8 markdown corpus and are tracked wholesale under hyperguild#138's parser consolidation rather than chased as individual rounds on this PR. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 9 ++++++-- ingestion/internal/mcp/tools_notes_test.go | 26 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index 043a217..5be4b4f 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -313,9 +313,14 @@ func frontmatterBlock(doc string) (block string, ok bool) { // Round 4 found a note with CRLF fence lines ("---\r\n") silently // defeats HasPrefix(doc, "---\n") on the precondition read — not an // adversarial old_str/new_str, just an encoding difference — which - // makes checkClaimEnvelope no-op entirely. Normalize once, here, so + // makes checkClaimEnvelope no-op entirely. Round 5 found the same + // mechanism one layer earlier: a leading UTF-8 BOM (a live, plausible + // byte sequence — Notepad's default "UTF-8" save and PowerShell's + // pre-v6 Out-File/Set-Content both emit one) defeats the same prefix + // check before CRLF normalization even applies. Strip both, here, so // every caller (hadFence/hasFence detection and frontmatterScalars) - // agrees regardless of line ending. + // agrees regardless of encoding. + doc = strings.TrimPrefix(doc, "\xef\xbb\xbf") doc = strings.ReplaceAll(doc, "\r\n", "\n") if !strings.HasPrefix(doc, "---\n") { return "", false diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index d991bf2..01b18a1 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -223,6 +223,32 @@ func TestBrainEdit_RejectsAnInvalidEnvelopeOnACRLFFencedNote(t *testing.T) { assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical") } +func TestBrainEdit_RejectsAnInvalidEnvelopeOnABOMPrefixedNote(t *testing.T) { + // Found in round-5 review: the same "encoding property empties + // frontmatterScalars's map" mechanism as the CRLF bypass, one layer + // earlier — a leading UTF-8 BOM (EF BB BF) defeats + // strings.HasPrefix(doc, "---\n") exactly like CRLF did, before that + // prefix is even reached. Notepad's default "UTF-8" save and + // PowerShell's pre-v6 Out-File/Set-Content both emit a BOM, so this is + // a live, plausible authoring path, not an exotic one — same severity + // and same one-line cost as the CRLF fix, fixed the same way rather + // than deferred to hyperguild#138. + dir := newBrainRepo(t) + note := filepath.Join(dir, "wiki/homelab/facts/n.md") + require.NoError(t, os.WriteFile(note, + []byte("\xef\xbb\xbf---\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") -- 2.54.0 From 42578c21b9d8e0cabb8279819594dc02422ef393 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 10:51:09 +0200 Subject: [PATCH 7/8] fix(mcp): fold YAML block-scalar continuations before validating the envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 review found a bypass in a different mechanism than rounds 1-6's fence-detection family: frontmatterScalars's indentation guard skips any indented continuation line outright, so old_str="half_life: durable", new_str="half_life:\n garbage-value" turns the field into a YAML block-scalar shape whose real value lives on the continuation line. present()'s own "empty value = placeholder, not an assertion" contract then treats the field as absent, and claim.ValidateEnvelope never sees the invalid value at all — no encoding quirk needed, one ordinary brain_edit call smuggles an out-of-enum value straight past the check. Fold indented continuation lines into the value when a top-level key's inline value is empty, so oneOf() still sees what was actually written. A genuinely empty value (no indented line follows) still folds to "", preserving the existing placeholder-not-assertion behavior for legitimately unset fields. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 30 ++++++++++++++++++-- ingestion/internal/mcp/tools_notes_test.go | 32 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index 5be4b4f..617d596 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -344,7 +344,9 @@ func frontmatterScalars(doc string) map[string]string { if !ok { return out } - for _, line := range strings.Split(block, "\n") { + lines := strings.Split(block, "\n") + for i := 0; i < len(lines); i++ { + line := lines[i] if line != "" && (line[0] == ' ' || line[0] == '\t') { continue } @@ -352,7 +354,31 @@ func frontmatterScalars(doc string) map[string]string { if !ok { continue } - out[strings.TrimSpace(key)] = strings.TrimSpace(val) + val = strings.TrimSpace(val) + if val == "" { + // Round 6: a colon with nothing after it is ambiguous — it can + // be a genuinely empty/omitted value, or the start of a YAML + // block scalar whose real value lives on the indented + // continuation line(s) that follow (e.g. "half_life:\n + // durable"). Treating the latter as absent let an edit smuggle + // an invalid value straight past ValidateEnvelope: the field + // read as unset while the disk actually carried a value. Fold + // the continuation in as a best-effort join so oneOf() still + // sees it. A genuinely empty value (no indented line follows) + // still folds to "", preserving present()'s existing + // placeholder-not-assertion contract. + var cont []string + for i+1 < len(lines) { + next := lines[i+1] + if next == "" || (next[0] != ' ' && next[0] != '\t') { + break + } + cont = append(cont, strings.TrimSpace(next)) + i++ + } + val = strings.Join(cont, " ") + } + out[strings.TrimSpace(key)] = val } return out } diff --git a/ingestion/internal/mcp/tools_notes_test.go b/ingestion/internal/mcp/tools_notes_test.go index 01b18a1..37dbf79 100644 --- a/ingestion/internal/mcp/tools_notes_test.go +++ b/ingestion/internal/mcp/tools_notes_test.go @@ -249,6 +249,38 @@ func TestBrainEdit_RejectsAnInvalidEnvelopeOnABOMPrefixedNote(t *testing.T) { assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical") } +func TestBrainEdit_RejectsAFoldedScalarThatHidesAnInvalidValue(t *testing.T) { + // Found in round-6 review: a different mechanism from the encoding + // family (rounds 1-6 were all about fence detection; this is the field + // extractor). old_str="half_life: durable", new_str="half_life:\n + // garbage-value" turns the field into a YAML block-scalar shape: the + // top-level line is now "half_life:" with an empty inline value, and + // the real value lives on the indented continuation line that follows. + // frontmatterScalars's indentation guard skips that continuation line + // outright, so the field reads as present-with-empty-value — + // present()'s own contract then treats that as absent (a placeholder, + // not an assertion) — and claim.ValidateEnvelope never sees the + // invalid value at all. No encoding quirk needed: one ordinary + // brain_edit call smuggles an out-of-enum value straight past the + // check the field was supposed to be validated by. + 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": "half_life: durable", + "new_str": "half_life:\n garbage-value", + }) + require.Contains(t, resp, "error", "%v", resp) + + got, err := os.ReadFile(note) + require.NoError(t, err) + assert.Contains(t, string(got), "half_life: durable", "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") -- 2.54.0 From 442557ca313cd86cc249d5f8050f553b59e85ebc Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 23 Sep 2026 12:25:30 +0200 Subject: [PATCH 8/8] docs(mcp): document checkClaimEnvelope's accepted v1 gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 7's review found an 8th bypass in the same "value hidden from the extractor" family as round 6, inside the fold logic round 7's own fix had just added (a blank line interrupting a folded continuation) — the second distinct gap found on the first alternate shape tried against that fix. Decision (Mathias, via the running sweep session): ship this PR's rounds 1-7 as v1 rather than continue incremental patches past the point of diminishing returns. Every bypass found across all 7 rounds requires the caller to already hold brain_edit write access and to deliberately construct a specific malformed-YAML shape with knowledge of this exact gap — narrow and low-likelihood next to another patch cycle. The real fix (one shared, tested, encoding-normalized frontmatter parser, replacing the three independent hand-rolled implementations this bug class keeps surfacing in) is tracked separately as hyperguild#138. Document the boundary directly on checkClaimEnvelope so the next person reading this function knows what's covered and what's a deliberate, recorded trade-off rather than an unnoticed gap. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/mcp/tools_notes.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ingestion/internal/mcp/tools_notes.go b/ingestion/internal/mcp/tools_notes.go index 617d596..0f9ba0c 100644 --- a/ingestion/internal/mcp/tools_notes.go +++ b/ingestion/internal/mcp/tools_notes.go @@ -268,6 +268,27 @@ func touchesFrontmatter(oldStr, newStr string) bool { // 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. +// checkClaimEnvelope previews what old_str->new_str would produce and +// validates the resulting claim envelope before the real edit runs +// (hyperguild#123). Seven review rounds closed every plausible bypass in the +// fence-detection family (field-name substring, block-containment, +// fence-stripping, CRLF, UTF-8 BOM) and the first instance of a +// value-extraction gap (a folded YAML block scalar hiding a value behind an +// empty inline colon). +// +// Known, accepted v1 gap: frontmatterScalars's block-scalar fold is a +// best-effort approximation, not a YAML parser — a blank line interrupting a +// folded continuation, or other contrived multi-line YAML shapes not yet +// probed (block-scalar indicators `|`/`>`, inline comments on a key line, +// quoted multi-line values), can still hide an invalid value from +// ValidateEnvelope. Accepted deliberately rather than chased indefinitely: +// every bypass found so far requires the caller to already hold brain_edit +// write access and to deliberately construct a specific malformed-YAML shape +// with knowledge of this exact gap — a narrow, low-likelihood threat next to +// the diminishing returns of another incremental patch round. The real fix +// (a shared, tested, encoding-normalized frontmatter parser used by all +// three independent implementations in this codebase) is tracked separately +// as hyperguild#138, not bolted on here piece by piece. func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error { if oldStr == "" { // hyperguild#115: strings.Replace treats "" as matching position 0, -- 2.54.0