Audit remaining write paths that reach disk without claim-envelope validation (brain_edit, ingestion pipeline) #123
Closed
opened 2026-09-22 21:20:52 +00:00 by mathias
·
7 comments
No Branch/Tag Specified
main
fix/pipeline-writes-158
fix/edit-path-io-192
fix/create-regular-188
fix/guard-special-179
fix/linkcheck-keeps-write-178
fix/write-dir-redirect-173
fix/tunnel-detect-174
fix/pipeline-root-157
w3/154-mcp-auth-wiring
fix/tunnel-traversal
test/in-brain-targets-171
fix/brain-scan-root-157
fix/graph-wiki-root-157
fix/vectorstore-root-157
fix/notes-root-157
fix/search-root-157
fix/brainwide-root-157
fix/raw-symlinks-153
feat/symlink-tripwire-157
fix/rest-auth-149
fix/123-brain-edit-envelope-check
feat/106-page-aware-extraction
fix/105-source-drift-on-main
fix/104-surface-merge-conflicts-on-main
agent/b1-compile-on-ingest
agent/104-surface-merge-conflicts
agent/105-source-drift
agent/107-loud-unconfigured-alerting
feat/brain-read-edit
fix/capture-i1-refusal-names-target
renovate/configure
feat/consolidate-single-harness
icebox/cmd-routing
feat/brain-pending-promote
fix/close-session-classification-refresh
fix/capture-summarywriter
v0.24.0
v0.23.1
v0.23.0
v0.22.0
v0.21.1
v0.21.0
v0.20.1
v0.20.0
v0.19.1
v0.19.0
v0.18.4
v0.18.3
v0.18.2
v0.18.1
v0.18.0
v0.17.0
v0.16.1
v0.16.0
v0.15.0
v0.14.0
v0.13.2
v0.13.1
v0.13.0
v0.12.1
v0.12.0
v0.11.2
icebox/cmd-routing-2026-07-01
v0.11.1
v0.11.0
v0.10.0
v0.9.0
v0.8.0
v0.7.0
v0.6.0
v0.5.0
v0.4.1
v0.4.0
v0.3.0
v0.2.0
v0.1.0
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#123
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-up from hyperguild#111's last acceptance box ("check whether any other write path reaches disk without passing through writeHallNote — the validator was attached to a function rather than to the boundary").
#111 fixed
UpdateNote(thebrain_updatesupersede path). A grep for every disk-write site iningestion/internal/while fixing it surfaced others not yet audited for the same gap:internal/notes/edit.go(brain_edit) — a generic byte-levelold_str/new_strreplace. It has zero frontmatter awareness, so a caller can rewrite an existinghalf_life:/applies:/claim_provenance:line directly via string replace with no envelope check at all. Arguably the most exposed path: it's model-facing today (s.brainEdit, unlikebrain_edit_root).internal/api/promote.go(brain_promote, pending → live) — not checked.internal/pipeline/pipeline.go,internal/pipeline/backfill.go,internal/watcher/watcher.go— the claudewatcher auto-ingest path (the actual ingestion half of the brain, not the MCP-facing half). Worth checking whether any of these can emit envelope fields at all today, and if a future feature adds that, whether it would go through a validated door.Ask
For each: confirm whether it can currently write claim-envelope fields to disk, and if so, route it through
claim.ValidateEnvelope(or confirm it structurally cannot and note why, so this doesn't get re-litigated).brain_editis the one worth prioritizing — it's the one live, model-facing gap today, not a hypothetical future one.Not in scope here
This is an audit-and-close, not a redesign. If the answer for a path is "it can't emit these fields, here's why," that's a valid close — don't invent envelope-awareness for a primitive that's deliberately generic (
brain_edit's docstring: "does NOT stamp updated_at or supersedes... it is a byte-level patch, not a metadata operation" — same tension applies to the envelope, and the right call may be to document the risk rather than bolt on parsing).Refs: #111, #102, brain#21.
Created via git-mcp on behalf of @mathias
Round 2 (PR #136, commit
062574d) closes round 1's field-name bypass but introduces a new, reproducible bypass in the same function. Verdict: not merged, blocking.New bypass:
old_strspanning a frontmatter fence boundarycheckClaimEnvelopegates onstrings.Contains(block, oldStr)— i.e. whetherold_stris fully contained within the extracted frontmatter block.frontmatterBlockdeliberately excludes both---fence lines fromblock. So anyold_strthat starts inside the frontmatter but is extended to include the closing fence (or the opening fence) fails this containment check and the whole envelope validation is skipped — whilenotes.Edititself has no notion of fences at all and applies the replace verbatim to the raw file.Repro (added as
TestAdversarial_FenceCrossingOldStrBypassesEnvelopeCheck, run against062574d)Note body:
Call
brain_editwith:old_str = "asserted\n---\n\nbody"new_str = "guessed\n---\n\nbody"old_strstarts on theclaim_provenanceline (last line of the block) and is extended three bytes past the closing fence into the body — a natural thing to do sincenotes.Editrequiresold_strto be unique, and trailing context past a short value is a normal way to disambiguate.strings.Contains(block, oldStr)is false (old_str isn't fully insideblock), socheckClaimEnvelopereturnsnil(skip/allow) unconditionally — no read ofafter, noValidateEnvelopecall at all.notes.Editthen runs unopposed:old_stroccurs exactly once in the full file, so the edit succeeds. Result written to disk:claim_provenance: guessedis not in the enum (asserted|inferred) — this is exactly the invalid-envelope-on-disk outcome #123 and round 1's fix were both supposed to close, now reachable again via a boundary-crossingold_strinstead of a field-name-omitting one. The same class of bypass also applies toold_strthat starts before the opening fence and crosses into the block (e.g.old_strbeginning with"---\ntitle: ...").Why the fix doesn't cover it
ingestion/internal/mcp/tools_notes.go,checkClaimEnvelope(currently ~line 271-283):The comment's premise ("old_str isn't in the frontmatter block at all") is false whenever old_str spans a fence boundary — it is partly in the block, just not fully contained by the substring-of-block test. The gate conflates "does this edit touch the envelope" with "is old_str a strict substring of the extracted block," and those differ exactly at both fence edges.
Suggested direction for round 3
Don't gate validation on
old_str's relationship toblockat all — the replace is cheap (already computed for the check). Computeafter := strings.Replace(before, oldStr, newStr, 1)unconditionally (or gated only on a cheap "could this write possibly touch bytes 0..len(opening---\n)+len(block)+len(closing fence)" position check, not a pure substring test), then re-extract frontmatter fromafterviafrontmatterBlock/frontmatterScalarsand compare against the frontmatter extracted frombefore— validate whenever the two differ, regardless of how the diff was expressed textually. That closes both the round-1 (field-name) and round-2 (fence-boundary) bypass classes with the same mechanism, because it stops asking "did old_str name a field / sit inside a block" and starts asking "did the resulting frontmatter change," which is what actually matters.What was verified as working in round 2
old_str="asserted",new_str="guessed"fully inside the block) — correctly rejected, confirmed via flip test: forcingcheckClaimEnvelopeto alwaysreturn nilturns exactlyTestBrainEdit_RejectsAnInvalidClaimEnvelopeWrittenViaStringReplaceandTestBrainEdit_RejectsAValueOnlyEditThatBreaksTheEnvelopered (and no others) — the tests are real, not decorative.checkClaimEnvelopecorrectly no-ops (frontmatterBlockreturnsok=false→ skip), confirmed by code reading;notes.Editremains the sole decision-maker for whether such notes are editable at all — no forever-block risk.go build ./...,go vet ./...,go test ./... -race -count=1all green across every package, not justinternal/mcp.062574d):CI / Lint / Test / Vetsuccess.None of the above changes the verdict: the fence-boundary bypass is real, reproducible, and writes an invalid claim-envelope value straight to disk with no error — the same failure mode #123 exists to prevent. Please fix in a round 3 before this merges.
Round 3 (PR #136, commit
0f932d8) closes both round-1 (field-name substring gate) and round-2 (block-containment gate) bypass classes by removing the gate entirely —checkClaimEnvelopenow unconditionally computesafter := strings.Replace(before, old_str, new_str, 1)and validatesfrontmatterScalars(after), regardless of whereold_strsits relative to the fence. Flip-tested: forcing the check to validatebeforeinstead ofafterturns exactly the three existing regression tests red (TestBrainEdit_RejectsAnInvalidClaimEnvelopeWrittenViaStringReplace,TestBrainEdit_RejectsAValueOnlyEditThatBreaksTheEnvelope,TestBrainEdit_RejectsAnOldStrThatCrossesTheClosingFence), and restoring it turns them green again — the tests are real, not decorative.go build ./...,go vet ./...,go test ./... -race -count=1all green across every package. CI (Gitea Actions run 7914, commit0f932d8):CI / Lint / Test / Vetsuccess.Verdict: still not merged, blocking. A fourth bypass exists, in a different class than rounds 1-2 — not "the gate wrongly decides to skip the check" (there is no gate left to skip), but "the check runs unconditionally, on the correct
afterstring, and still validates nothing because the edit itself destroyed the one landmark (frontmatterBlock's closing---fence) thatfrontmatterScalarsneeds to see any field at all."New bypass: an edit that deletes the closing fence while it changes an envelope value
checkClaimEnvelope→frontmatterScalars(after)→frontmatterBlock(after), which returnsok=falsewheneverafterhas no\n---\nafter the opening fence. Onok=false,frontmatterScalarsreturns an empty map, andclaim.ValidateEnvelope(map[string]string{})returnsnilby design ("absent fields are valid" — the doc comment's own justification for opt-in adoption on a pre-existing unfenced note). That design intent was for notes that never had a fence. It also fires for a note that had a valid fence one instant before this exact edit, if the edit's ownnew_strdoesn't reproduce the closing fence.Repro
Note body (valid envelope, all three fields present and legal):
Call
brain_editwith:old_str = "claim_provenance: asserted\n---\n"new_str = "claim_provenance: not-a-real-value\n"(
old_stris unique in the file — satisfiesnotes.Edit's own uniqueness contract, so this is a single, ordinarybrain_editcall, not a multi-step exploit.)checkClaimEnvelopecomputes:correctly — no gate skipped this time. But
frontmatterBlock(after)finds the opening---, then searches the rest for\n---\nand finds none (the edit deleted it), so it returnsok=false.frontmatterScalarsreturns{}.claim.ValidateEnvelope({})returnsnil.checkClaimEnvelopereturnsnil.notes.Editthen runs unopposed and writes:to disk, no error returned to the caller.
claim_provenance: not-a-real-valueis not in the enum (asserted|inferred) — the exact invalid-envelope-on-disk outcome #123 exists to prevent — and the note is left permanently unfenced (no closing---anywhere), so every other consumer of this note's frontmatter (the wing index,brain_get, any futurefrontmatterScalars-based read) now sees zero frontmatter fields for it, silently, until a human notices and repairs the file by hand.Verified directly against
0f932d8with a standalone test exercisingbrain_editover JSON-RPC exactly asTestBrainReadAndEdit_RoundTripThroughJSONRPCdoes (not committed to the branch — reviewer-side probe only):Confirmed: no error, write succeeds, invalid value on disk, fence gone.
Why this is a different class, not another instance of rounds 1-2
Rounds 1 and 2 were both "a condition wrongly decides the check should not run." This PR closed that whole class by deleting the condition —
checkClaimEnvelopenow always runs and always inspectsafter. This bypass doesn't route around the check; it feeds the check a value (after) that is honestly computed but structurally unparseable byfrontmatterBlock's fence-pair requirement, so the check's own input, not its gate, is what's degraded. The three existing regression tests don't cover it because all three preserve the closing fence innew_str; only the edit itself removing the fence triggers this, which is exactly item 3 of the round-3 task brief ("can an edit corrupt the frontmatter fence itself... silently dropping envelope enforcement for that edit while still writing structurally broken frontmatter").Scope
This is squarely #123, not a separate concern: the issue's ask for
brain_editis "confirm whether it can currently write claim-envelope fields [with invalid values] to disk, and if so, route it throughclaim.ValidateEnvelope." It still can, via this path —not-a-real-valuereached disk asclaim_provenancewith zero validation error. Recommend closing this specific gap by havingcheckClaimEnvelopetreat "after has no closing fence but before did" as a failure in itself (frontmatter-corruption is its own reason to refuse the write), independent of whateverfrontmatterScalarscan or cannot extract from the corrupted result — rather than trustingfrontmatterBlock'sok=falseto always mean "nothing to validate."What else was checked and found solid in this round
old_str(once outside frontmatter, once inside, or vice versa): confirmednotes.Edit'scountOverlappingcheck (edit.go) enforces whole-file uniqueness independent ofcheckClaimEnvelope, and rejects withErrAmbiguousbefore any write —checkClaimEnvelope's own first-occurrence-onlystrings.Replacepreview can never diverge from what actually gets written, because a divergence would require an ambiguousold_str, whichnotes.Editrefuses regardless of whatcheckClaimEnvelopedecided.claim_provenance: xafter the closing---): confirmedfrontmatterScalarsis fence-scoped and does not see it, so it isn't validated — but it also isn't frontmatter by this codebase's own definition (frontmatterBlock/api.ReadNote'sparseFrontmatterare both fence-scoped everywhere they're used), so a body line that merely looks likekey: valueprose was never in scope for the claim envelope. Not a gap.checkClaimEnvelope's ownnotes.Readandnotes.Edit's later locked read: real in principle (two unsynchronised reads of the same file), but pre-existing and systemic —notes.Editre-validates uniqueness against its own fresh, locked read regardless of whatcheckClaimEnvelopesaw, so a stalebeforein the envelope check can produce an incorrect validation verdict (validate against content that's about to be overwritten by a concurrent writer) but cannot itself corrupt the file or bypassnotes.Edit's own safety checks. Out of scope for #123; if it matters it's a separate, existing property of everynotes.Read-then-notes.Editcaller in this codebase, not introduced by this PR.Please fix the fence-corruption case in a round 4 before this merges.
Round 4 (PR #136, commit
110e8b8) correctly closes round 3's exact repro — an edit whosenew_strdrops the closing fence is now rejected via the newhadFence && !hasFencecheck. Verdict: still not merged, blocking. A fifth bypass exists, in a different class from rounds 1-4: not a craftedold_str/new_strshape at all, but an encoding property of the note file that silently disables the entirecheckClaimEnvelopefunction, not just this round's fence-integrity addition.Flip-tests performed (round 4's own claims, independently verified)
hadFence && !hasFence(if false && hadFence && !hasFence):TestBrainEdit_RejectsAnEditThatRemovesTheClosingFencewent red. Restored: green. The check is real, not decorative.oldStr == ""early-return:TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(hyperguild#115's regression guard) went red —"claim envelope: edit would remove the frontmatter fence"instead of the expected"old_str is empty"message, confirming the early-return is load-bearing and not incidental. Restored: green.go build ./...,go vet ./...,go test ./... -race -count=1: all green across every package.110e8b8):Lint / Test / Vetsuccess.git status --porcelainclean,git diff --statempty).New bypass: CRLF-fenced notes silently disable the whole check (not just round 4's addition)
frontmatterBlock— the single primitive every round of this fix has been built on top of — requires an exactstrings.HasPrefix(doc, "---\n")(LF) to recognize the opening fence at all:If a note's frontmatter fence lines use CRLF (
"---\r\n"instead of"---\n"), this returnsok=falseunconditionally — not because anything is wrong with the note, but because"---\r\n"does not literally start with"---\n". That makeshadFencefalse on thebeforeread for any CRLF-fenced note, valid or not. Downstream:hadFence && !hasFencecan never fire (hadFenceis already false) — round 4's own new check is a no-op for this class.frontmatterScalars(before)/frontmatterScalars(after)both returnfrontmatterBlock'sok=falsepath → empty map.claim.ValidateEnvelope(map[string]string{})returnsnilby design ("absent fields are valid").checkClaimEnvelopereturnsnil— the edit is allowed, unconditionally, regardless of whatold_str/new_strare.This is not a crafted bypass string — it requires no adversarial
old_str/new_strshape at all. It is a property of the note's line endings, orthogonal to every bypass class in rounds 1-4.Repro (verified against
110e8b8, reviewer-side probe, not committed)Result: no
"error"key in the response. File on disk after:claim_provenance: not-a-real-value(not in the enumasserted|inferred) written to disk, zero validation error — the exact outcome #123 exists to prevent, on a note that isn't even trying to be adversarial, just CRLF-formatted.Why this is worse than rounds 1-3, and different from round 3
Rounds 1-3 required the caller to phrase
old_str/new_stra specific adversarial way on every single bypassing edit. This bypass, once a note is CRLF-fenced by any means (a Windows-authored note, a paste from a Windows tool, an external ingestion path, or — separately worth checking — whethergit'score.autocrlfor any tool in this pipeline could normalize line endings on checkout/write), silently and permanently disablescheckClaimEnvelopefor every futurebrain_editcall on that note, with no crafting required for any of them. It is a false negative on the precondition read (before), not on the edit's content — exactly the shape flagged as worth checking in this round's brief.Scope check against the real corpus
Checked the live brain corpus (
/var/lib/supervisor/brain/wiki,/var/lib/supervisor/brain/knowledge, 1452 files): zero notes currently have a CRLF fence line (grep -lP '^---\r$'— no matches). Some archived claude-session transcripts do contain CRLF bytes in their body (mixed line endings from pasted terminal output), but not at the fence boundary, so today's exposure is zero. That is a fact about current data, not a property of the code — nothing innotes.Edit,notes.Read, orcheckClaimEnveloperejects or normalizes a CRLF-fenced note, so the gap is live and waiting for the first note (from any source) that has one.Three other angles probed and found not to be live bypasses
---\n...\n---\nfence pair injected bynew_str(sofrontmatterBlockparses a truncated first block while a real field with an invalid value sits past it, unseen): constructed and ran.api/frontmatter.go'sparseFrontmatter— the canonical reader used elsewhere in the codebase — uses the identical "first\n---\nwins" algorithm asfrontmatterBlock, so both agree the injected second block is body text, not frontmatter, consistently. In one variant this was additionally caught byValidateEnvelope's own presence rule (claim_provenance is required when half_life or applies is set); in a second variant it wrote successfully, but per this codebase's own definition of "frontmatter" (shared by every fence-aware reader I found), the writtenclaim_provenance: not-a-real-valueline is body prose, not envelope data — not a bypass of the envelope invariant, though it does produce a visually-confusing double----file that a human would misread. Worth a lint rule, not a merge-blocker.old_strspanning the very first---\n, hopinghasFence'sHasPrefixcheck onafterdoesn't catch it symmetrically): it does.hasFenceis derived fromfrontmatterBlock(after), whose first condition isHasPrefix(after, "---\n")— any edit that removes/shifts the genuine leading fence makeshasFencefalse, andhadFencewas true, sohadFence && !hasFencefires exactly as it does for the closing-fence case. Confirmed by inspection; the check is symmetric.notes.Edituniqueness divergence: re-confirmed round 3's finding still holds —checkClaimEnvelope's single-first-occurrence preview can never diverge from what's actually written, because a divergence requires an ambiguousold_str, whichnotes.Editindependently refuses via its own uniqueness check regardless of what the envelope preview decided.Recommendation
Do not merge as-is. Two options, not mutually exclusive:
before/after(strings.ReplaceAll(s, "\r\n", "\n")) before any fence detection incheckClaimEnvelope, matching whatever normalization (if any) the canonicalapi/frontmatter.goreader should also apply — check that one too, since it has the sameHasPrefix(src, "---\n")LF-only assumption and is used on the real write/read path, not just this preview.tools_notes.go:frontmatterBlock,api/frontmatter.go:parseFrontmatter/split,internal/frontmatter/frontmatter.go:split, the last of which uses a looser"\n"+fencesubstring match with no trailing-newline requirement, diverging from the other two). Recommend consolidating to one frontmatter-block extraction function, used everywhere a fence boundary needs to be found, with newline normalization applied once at the boundary — rather than continuing to patchcheckClaimEnvelopein isolation each round. This does not need to happen before the CRLF fix specifically, but the CRLF bug is a direct symptom of not having done it: a single normalization point would have caught this by construction rather than requiring a fifth adversarial-review cycle to surface it.Please fix the CRLF case (at minimum) in a round 5 before this merges.
Round 5 (PR #136, commit
4f75279) correctly closes round 4's exact CRLF repro —frontmatterBlocknow normalizes\r\n→\nonce before fence detection, soTestBrainEdit_RejectsAnInvalidEnvelopeOnACRLFFencedNotepasses.Flip-tests performed (independently verified)
doc = strings.ReplaceAll(doc, "\r\n", "\n")fromfrontmatterBlock:TestBrainEdit_RejectsAnInvalidEnvelopeOnACRLFFencedNotewent red (edit succeeded with no error,claim_provenance: guessedwritten to a CRLF-fenced note). Restored: green. The check is real, not decorative.TestBrainEdit*/TestBrainEditRoot*suite run with the line removed: exactly that one test failed, nothing else regressed — round 4's own history (an earlier fix brokeTestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes, hyperguild#115's guard) did not recur here.go build ./...,go vet ./...,go test ./... -race -count=1: all green across every package (31 packages, allokorno test files).4f75279):CI / Lint / Test / Vetsuccess.Verdict: still not merged, blocking. A sixth bypass exists, in the same family the round-4 fix was scoped to (an encoding property in front of the opening fence that
frontmatterBlock's literalHasPrefix(doc, "---\n")doesn't recognize) but a different specific byte sequence.New bypass: a UTF-8 BOM before the opening fence silently disables the whole check
frontmatterBlocknormalizes CRLF but does nothing about a leading byte-order-mark (EF BB BF, U+FEFF). A note saved by Notepad (BOM-by-default on "UTF-8" in modern Windows), PowerShell's historicalOut-File/Set-Contentdefault encoding (UTF-8 with BOM, pre-PS6), or any BOM-emitting Windows tool starts with---\ninstead of---\n.strings.HasPrefixfails on that first byte,frontmatterBlockreturnsok=falseon thebeforeread exactly as an unrecognized-CRLF fence did pre-round-5 —hadFenceis false,hadFence && !hasFencecan never fire,frontmatterScalarsreturns an empty map,claim.ValidateEnvelope({})isnilby design,checkClaimEnvelopereturnsnilunconditionally.Repro (reviewer-side probe, not committed to the branch — same pattern as rounds 3-4's probes)
Result: no
"error"key. File on disk after:"---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: guessed\n---\n\nbody\n"—claim_provenance: guessed(not in the enumasserted|inferred) written with zero validation error, on a note with no adversarialold_str/new_strshape — value-only edit, identical to the very first repro that opened this issue.Also checked lone
\r(classic-Mac line endings, no\nat all): same mechanism, also bypasses. Per the task's own calibration and consistent with round 4's own scoping, judged not worth blocking on — old-Mac-style bare-CR files essentially don't occur in this git-tracked UTF-8 corpus and there is no plausible authoring path that produces them here, unlike BOM which several common Windows tools emit by default.Scope check against the real corpus
/var/lib/supervisor/brain/wiki+/var/lib/supervisor/brain/knowledge(1452 files): zero notes currently start with a UTF-8 BOM. Same shape of finding as round 4's CRLF check — zero exposure in current data, nothing in the code prevents the first one.Recommendation
This is the same mechanism as round 4's CRLF finding (an encoding property in front of the fence, not a crafted
old_str/new_str), the same severity (silent invalid-enum-value write, permanent for every future edit on that note), and the same cost to fix (one more normalization line: strip a leading/EF BB BFbefore theHasPrefixcheck, mirroring the CRLF normalization just added). Given round 4 was treated as blocking for an equivalent-shape, equivalent-cost fix, consistency says this should be too — I recommend fixing it inline in a round 6 rather than deferring solely to hyperguild#138.That said, this is now the fifth distinct encoding/shape variant found in five rounds against the same hand-rolled fence detector (field-name gate, block-containment gate, fence-integrity, CRLF, now BOM), and each fix has covered exactly the one shape that was found, not the class. hyperguild#138 (consolidate the three duplicate frontmatter-fence parsers into one, encoding-normalized at a single boundary) is the fix that stops this pattern by construction instead of by the next adversarial pass finding the next byte sequence (NEL, a stray leading blank line, UTF-16 BOM, indentation before the fence, etc. are all still open questions I did not individually test, in line with the diminishing-returns calibration for this round). If the goal is to stop paying for a round 7, 8, 9 one shape at a time, #138 is the point at which that should happen — but BOM specifically, being a live and plausible risk today (not a hypothetical), is not deferred as fully as those untested exotic instances.
Please fix the BOM case in a round 6 before this merges, or make an explicit, documented call to defer it into #138 and accept the residual risk in writing.
Round 6 (PR #136, commit
1d67e16) independently re-verified and correctly closes round 5's exact BOM repro.Verification performed (this pass)
frontmatterBlock's newstrings.TrimPrefix(doc, "\xef\xbb\xbf")line: removed it, ranTestBrainEdit_RejectsAnInvalidEnvelopeOnABOMPrefixedNote— went red (edit succeeded with no error,claim_provenance: guessedwritten to a BOM-prefixed note). Restored the line: green again,git status --porcelainclean.TestBrainEdit*/TestBrainEditRoot*suite with the line removed: exactly that one test failed, nothing else regressed.go build ./...,go vet ./...,go test ./... -race -count=1: all green, 31 packages (okorno test files).1d67e16):CI / Lint / Test / Vetsuccess.HasPrefix" variant (NEL, UTF-16 BOM, lone CR, leading blank lines/whitespace before the fence) — per this round's scope directive, that class is deliberately drawn a line under and tracked wholesale under #138.New finding — NOT in the deferred encoding-family:
frontmatterScalarscan see a present, invalid envelope value as absentThis round's task was to re-read
checkClaimEnvelope+frontmatterBlock+frontmatterScalarsas a whole for anything outside the "encoding defeats fence detection" class — specifically whetherfrontmatterScalars's indentation-guardedkey:valueextraction is exercised correctly against real YAML shapes (comments, anchors, multi-line scalars). It is not.frontmatterScalarsis a naive line scanner: for each non-indented line it doesstrings.Cut(line, ":")and recordskey -> TrimSpace(value). It has no notion of YAML's plain-scalar line continuation, where a value is folded onto the next (indented) line:is valid YAML and a real parser reads
half_life: "durable".frontmatterScalarsinstead reads thehalf_life:line, gets an empty value (""), and — becausepresent()treats an empty value as absent (by design, for the "placeholder key" case) — reportshalf_lifeas not set at all. The next line (durable) is skipped outright by the indentation guard, so the real value is never seen by anything.Repro (reviewer-side probe, not committed — same pattern as rounds 3-5's probes)
Starting note (valid envelope):
A single, ordinary
brain_editcall —old_strunique, no fence-crossing, no ambiguous match, ordinary shape:Result: no
"error"key. File on disk after:half_life's real value,garbage-value, is not in the enum (volatile|seasonal|durable) — the exact invalid-envelope-on-disk outcome #123 exists to prevent — written with zero validation error.checkClaimEnvelopesawhasHalfLife=false(nothasHalfLife=true, value="garbage-value"), soValidateEnvelopenever even considered the field.Why this is a different bypass class, not a #138 duplicate
Every prior round (1–6) found ways to defeat
frontmatterBlock's fence detection (HasPrefix(doc, "---\n")/ the\n---\nsearch) — field-name gating, containment gating, fence-integrity, CRLF, BOM. #138 is scoped to that: "consolidate the three independent frontmatter-fence parsers." This finding is downstream of a correctly detected fence —frontmatterBlockreturnsok=trueand the right block text every time in this repro. The gap is infrontmatterScalars, the field-value extractor, which silently treats "value folded onto a continuation line" as "field absent." That is a structurally new root cause, not another instance of the one #138 tracks.Live/plausible assessment (per this round's calibration)
I judge this blocking, not deferrable to #138 alongside the untested exotic-encoding tail, for three reasons that distinguish it from what's being drawn a line under:
brain_editcall. Nothing about it depends on Notepad, PowerShell, or any tool's default output.brain_editis model-facing; #123's own framing is "the one live, model-facing gap today"). A model that has read this source file — or one instructed by injected content to defeat the envelope check — has, with this finding, a trivial one-line reformatting ofnew_strthat fully defeats validation. That is closer to the round-2 fence-crossing class (deliberately shaped, but a natural move given the tool's own constraints) than to a passive encoding artifact, and arguably more dangerous: it needs no unusual file property to already exist, just an edit call shaped this way.What I did not do
I did not attempt a fix — the right general fix here is a judgement call (join folded continuation lines before parsing? reject any envelope-field line with an empty inline value outright? something else?), and picking it well is exactly the kind of decision #138's "one shared, tested, encoding-normalized parser" is meant to settle once rather than have each round patch narrowly. I did not check comment-lines or YAML anchors further (both degrade safely to false-rejection in my read — fail-closed, not a bypass — but I did not exhaustively test every value shape).
Verdict: still not merged, blocking. Please either fix this in a round 7 (the same "live and plausible, fix inline" call round 6 itself made for BOM) or make an explicit, written call to accept the residual risk and merge anyway, same as round 5 asked for BOM.
Filed nowhere new yet — this is a distinct root cause from #138's scope; if the call is to defer rather than fix inline, it likely wants its own tracking issue rather than folding into #138's fence-parser-consolidation scope, since fixing #138's three fence parsers would not close this gap.
Round 7 (PR #136, commit
42578c2) correctly closes round 6's exact repro —frontmatterScalarsnow folds an indented continuation line into a top-level key's empty inline value beforeValidateEnveloperuns.Verification performed
if val == ""to never fire (if false && val == "").TestBrainEdit_RejectsAFoldedScalarThatHidesAnInvalidValuewent red (edit succeeded with no error). Restored: green,git status --porcelainclean,git diffempty.TestBrainEdit*/TestBrainEditRoot*suite with the fold disabled: exactly that one test failed, nothing else regressed — the legitimately-empty-field and unrelated-body-edit test cases (TestBrainEdit_UnrelatedEditsSkipTheEnvelopeCheckEntirely,TestBrainEditRoot_GovernanceFilesAreNeverEnvelopeChecked) still pass with the fold on, confirming it doesn't produce false positives on the existing suite.go build ./...,go vet ./...,go test ./... -race -count=1: all green, every package.42578c2):CI / Lint / Test / Vetsuccess.frontmatterScalars'slinesslice is built exclusively fromfrontmatterBlock's extracted block text, which by construction ends before the closing fence — there is no code path by which a body line can be read as a continuation, so no false-rejection risk on the fence boundary round 3 already found once in this file.Verdict: still not merged, blocking. An 8th bypass exists, in the exact same family as round 6's own finding (the field-value extractor, not fence detection) — not a new mechanism, a second gap in the very fold that round 7 just added.
New bypass: a blank line between the key and its continuation defeats the fold
The fold loop breaks the moment it sees a blank line:
If the line immediately following the empty-valued key is blank, the loop breaks on its first iteration,
contstaysnil, andvalfolds to""— exactly the pre-round-7 "present with empty value" misread, reached one line later than round 6's repro.Repro (reviewer-side probe, not committed to the branch — same pattern as rounds 3-6's probes)
Starting note (valid envelope):
A single, ordinary
brain_editcall —old_strunique, no fence-crossing, no encoding trick:Result: no
"error"key. File on disk after (confirmed by printing it in a probe test):half_life's real value,garbage-value, is not in the enum (volatile|seasonal|durable) — written with zero validation error.frontmatterScalarsreadshalf_lifeas absent (folds to""),applies/claim_provenanceare still present and individually valid, soValidateEnvelopesees nothing wrong.What I also checked and ruled out as non-bypasses (per this round's calibration)
"applies: bogus-value"), which is never a member of any of the three enums, sooneOf()rejects it. This fails closed, not open — not an independent bypass.Judgment call this round surfaces
This is the second distinct gap found inside the same ~15-line fold added this round, on the first alternate shape tried (a blank line — at least as mundane an authoring habit as round 6's own repro, arguably more so: adding a blank line for readability between a key and a wrapped value is an extremely ordinary human/model habit, not a crafted attack). Round 6's own review comment already flagged that picking the right general fix here "is exactly the kind of decision #138's one shared, tested, encoding-normalized parser is meant to settle" rather than another narrow patch. Finding a second hole in the first fix attempted, this quickly, is evidence for that read: the line-based, ad hoc fold is not covering the shape of the problem (YAML block-scalar folding), it is covering the one repro that was demonstrated. I would not be confident that a round-8 patch narrowly targeting "blank line inside the continuation" closes the class either — untested-by-me shapes in the same family include block-scalar indicators (
|,>), a#comment on the key's own line, quoted multi-line values, and mixed tab/space continuation indentation at a key's own boundary.Recommendation: this PR should pause here rather than take a round 8 narrow patch. The residual risk —
brain_edit's field-value extractor can still be defeated by an ordinary, non-adversarial YAML shape it doesn't recognize — should be documented and a real decision made on whether to (a) accept a v1 posture (fence-and-field checks with a documented, enumerated gap list) and close #123 on that basis, which the issue's own text explicitly allows ("the right call may be to document the risk rather than bolt on parsing"), or (b) invest in a real YAML-aware parser via #138 before #123 closes. That is Mathias's call, not mine to make by continuing to patch.Not merging this round.
brain_edit/brain_edit_rootwrites are otherwise unaffected — main is unchanged either way.Closed via PR #136 (squash-merged to main).
Summary
brain_editnow validates the claim envelope before writing, same asbrain_write/brain_update(#111). Getting there took 7 adversarial review rounds, each finding a genuine, reproduced bypass in the previous round's fix — recorded here for anyone touching this code next.The 8 bypasses found and closed
Fence-detection family (does the edit land inside/around a note's
---frontmatter fence):old_str/new_strhad to name the field (claim_provenance) to trigger validation; a value-only diff ("asserted"→"guessed") never named it and walked straight through.old_strfully inside the extracted frontmatter block; anold_strdisambiguated across the closing fence into the body failed containment and skipped validation.---degraded the post-edit doc to "unfenced," so the extractor saw an empty map andValidateEnvelope({})isnilby design.strings.HasPrefix(doc, "---\n")false-negatived on CRLF-fenced notes purely from line-ending encoding, no adversarial crafting needed.Value-extraction family (does the extractor see the value that's actually on disk):
6. Folded YAML block scalar —
old_str="half_life: durable",new_str="half_life:\n garbage-value"made the field read as present-with-empty-value, which the codebase's own "empty = placeholder, not an assertion" contract treats as absent — the invalid value was never seen byValidateEnvelope.7. Blank-line-interrupted fold — the fix for #6 broke on the very next shape tried: a blank line between the key and its continuation stopped the fold before consuming any text.
Each was flip-tested independently by the reviewing agent (revert just the fix, confirm the regression test goes red for the right reason; restore, confirm green) before being accepted as real.
Decision: ship v1, document the residual gap
Round 7 found gap #7 inside the very fix that closed gap #6 — the second distinct hole on the first alternate shape probed. Continuing to patch incrementally was clearly hitting diminishing returns, so this was escalated to Mathias as a real decision rather than ground through further: ship the current state as v1 with a documented, accepted gap, or block the merge on building a real YAML-aware parser first.
Decision: ship v1. Rationale — every bypass found across all 7 rounds requires the caller to already hold
brain_editwrite 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 residual gap (block-scalar fold's handling of blank-line-interrupted continuations, and untested-but-plausible neighbors:|/>block-scalar indicators, inline comments on a key line, quoted multi-line values) is documented directly oncheckClaimEnvelopeininternal/mcp/tools_notes.go.The actual fix — one shared, tested, encoding-normalized frontmatter parser replacing the three independent hand-rolled implementations this bug class keeps surfacing in (
internal/mcp/tools_notes.go,internal/api/frontmatter.go,internal/frontmatter/frontmatter.go) — is tracked separately as hyperguild#138, deliberately not bolted onto this PR's narrower scope.Verification evidence
go build ./...,go vet ./...,go test ./... -race -count=1green across all 31 packages, re-run after every round.task check(full gate incl. gitleaks) green.442557c,CI / Lint / Test / Vet): success.🤖 Generated with Claude Code
Created via git-mcp on behalf of @mathias