Commit Graph
10 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5 42578c21b9 fix(mcp): fold YAML block-scalar continuations before validating the envelope
CI / Lint / Test / Vet (pull_request) Successful in 33s
CI / Mirror to GitHub (pull_request) Skipped
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 <noreply@anthropic.com>
2026-09-23 10:51:09 +02:00
mathiasandClaude Sonnet 5 1d67e16c0d fix(mcp): strip a leading UTF-8 BOM before frontmatter fence detection
CI / Lint / Test / Vet (pull_request) Successful in 42s
CI / Mirror to GitHub (pull_request) Skipped
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 <noreply@anthropic.com>
2026-09-23 10:44:54 +02:00
mathiasandClaude Sonnet 5 4f75279ceb fix(mcp): normalize CRLF before frontmatter fence detection
CI / Lint / Test / Vet (pull_request) Successful in 33s
CI / Mirror to GitHub (pull_request) Skipped
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 <noreply@anthropic.com>
2026-09-23 10:40:00 +02:00
mathiasandClaude Sonnet 5 110e8b8f75 fix(mcp): reject edits that strip a note's frontmatter fence
CI / Lint / Test / Vet (pull_request) Successful in 34s
CI / Mirror to GitHub (pull_request) Skipped
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 <noreply@anthropic.com>
2026-09-23 10:32:46 +02:00
mathiasandClaude Sonnet 5 0f932d8f77 fix(mcp): drop the containment gate, always validate the post-edit envelope
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped
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>
2026-09-23 10:27:30 +02:00
mathiasandClaude Sonnet 5 062574dad8 fix(mcp): close claim-envelope bypass on value-only brain_edit diffs
CI / Lint / Test / Vet (pull_request) Successful in 33s
CI / Mirror to GitHub (pull_request) Skipped
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>
2026-09-23 10:21:53 +02:00
mathiasandClaude Sonnet 5 f7fe00cecf fix(mcp): validate the claim envelope before brain_edit writes too
CI / Lint / Test / Vet (pull_request) Successful in 36s
CI / Mirror to GitHub (pull_request) Skipped
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>
2026-09-23 10:13:59 +02:00
mathias 5a5d938e8e Brain-quality sweep, batch 1: brain_edit/brain_update correctness + close-session docs (#124)
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-22 21:27:11 +00:00
mathias 36a45068ab feat(mcp): record and alert on a governance-file rewrite (#97) (#98)
CI / Lint / Test / Vet (push) Successful in 1m23s
CI / Mirror to GitHub (push) Successful in 4s
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>
2026-08-22 06:09:53 +00:00
mathias 055af368cd feat(mcp): brain_read + brain_edit, contained to the sync source (brain#4) (#96)
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Skipped
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>
2026-08-18 18:14:05 +00:00