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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
hyperguild#123, the audit follow-up from #111: writeHallNote and
UpdateNote both validate the claim envelope (half_life/applies/
claim_provenance) before writing; brain_edit didn't -- a byte-level
string replace has no frontmatter awareness at all, so a caller could
rewrite an existing envelope line (e.g. flip claim_provenance from
asserted to guessed, or set half_life to a value outside the enum)
straight past the validator. This was the one live, model-facing gap
#123 identified; the ingestion pipeline write paths audited alongside it
turned out not to touch these fields at all, so this closes the issue.
Fixed by previewing what old_str->new_str would produce -- the exact
strings.Replace(before, old_str, new_str, 1) formula notes.Edit itself
uses internally -- and validating the envelope on that preview before
the real edit is allowed to run. Fail-closed: an invalid result blocks
the edit entirely, matching create/supersede's existing posture. Scoped
to notes only (scope == notes.ScopeNotes) since governance files have no
claim-envelope concept, and gated on a cheap substring check so an
ordinary body edit that never mentions an envelope field name pays
nothing extra.
The preview doesn't replicate notes.Edit's uniqueness/CRLF/conflict-
marker checks -- if old_str is absent or ambiguous, the preview may not
match what Edit ultimately does, but Edit independently and correctly
rejects those cases at write time regardless, so nothing about write
safety rides on this preview's accuracy, only the envelope check itself.
frontmatterScalars is a third, minimal implementation of the same
indented-line-safe scalar extraction as the sibling packages
(hyperguild#126/#128) -- deliberately not shared/exported across
packages (each package's frontmatter handling has slightly different
needs and none currently import each other), but built the same way
from the start this time rather than needing a follow-up fix.
Flip-tested: disabling the pre-check gate lets the invalid-envelope test
fail (the edit silently succeeds); restoring it passes. Full module
suite green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
brain_edit_root was gated but unobserved: on success it emitted a slog.Warn
and nothing else. That proves a rewrite was allowed, not that one happened —
and brain-sync commits it within 15 minutes authored `brain-sync`, so git
history cannot tell a CLAUDE.md rewrite apart from routine note traffic.
A successful brain_edit_root now emits an ntfy notification and an AuditEntry
through the same sink a capture already uses. notes.Result gains
PrevContentHash so both ends of the change are identifiable.
Path and hashes only in the notification body. Reserved at Internal, not
Confidential, because Confidential makes DegradingSink refuse to record while
loki is down rather than buffer. Observation is best-effort and never fails
the call; a failure to observe logs at ERROR.
Ordinary brain_edit on knowledge//wiki/ is deliberately not audited — git
history already carries the same before/after bytes with a diff.
Closes#97.
Co-authored-by: Mathias <mthbqv@gmail.com>
brain_read returns a note verbatim; brain_edit is str_replace with an
exactly-once match, operating on the tree brain-sync commits from.
An adversarial design review before implementation changed three things:
1. Governance files are not in the model-facing tool. Brain content is
ingested from the outside world and reaches a model's context, and that
model would hold the editor — one poisoned page could rewrite the
CLAUDE.md a .dispatch-allow agent later loads as instructions. And
brain-sync merges with --strategy-option=ours, so the live tree beats a
human's corrective push on the conflicting lines. CLAUDE.md now goes
through brain_edit_root: de-surfaced from tools/list and gated on the
local static principal. The scopes are disjoint — the privileged door
has the smaller reach.
2. Reads needed the same allowlist, and brain_get already lacked it: it
returned verbatim session transcripts under archive/ by path. A test
proved the exposure live before the fix.
3. Canonicalise before allowlisting — knowledge/../.git/hooks/post-commit
would otherwise reach a hook that brain-sync executes in a pod holding
the Gitea push credentials.
Plus overlapping match counting, UTF-8 validation both ways, refusal while
git is mid-operation, flock with the read inside the lock, atomic
same-dir rename, and reindex only when frontmatter is touched.
Refs brain#4.
Co-authored-by: Mathias <mthbqv@gmail.com>