fix(mcp): validate the claim envelope before brain_edit writes too (#136)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s

7 review rounds closed 8 distinct bypasses across two mechanism families: fence-detection (field-name substring, block-containment, fence-stripping, CRLF, UTF-8 BOM) and value-extraction (folded YAML block scalar hiding an invalid value). A known v1 gap remains in the block-scalar fold's handling of blank-line-interrupted continuations — documented on checkClaimEnvelope, accepted deliberately (narrow, requires an already-privileged caller deliberately constructing a specific malformed-YAML shape) rather than chased past diminishing returns. Real fix (shared, tested, encoding-normalized frontmatter parser) tracked separately as hyperguild#138.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #136.
This commit is contained in:
2026-09-23 10:28:07 +00:00
committed by mathias
co-authored by Claude Sonnet 5
parent a5f502eb9c
commit e157311cd8
2 changed files with 412 additions and 0 deletions
+237
View File
@@ -93,6 +93,243 @@ 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_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_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_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_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_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_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")
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)