Commit Graph
2 Commits
Author SHA1 Message Date
mathias c2cdcb07f1 feat(mcp): record and alert on a governance-file rewrite (#97)
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped
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. CLAUDE.md is what the next agent loads as
instructions.

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. It leaves the cluster for
a phone via a third-party topic, and a hash identifies the rewrite while
the bytes would leak whatever was written into an instruction file.

Reserved at Internal, not Confidential: Confidential makes DegradingSink
refuse to record while loki is down rather than buffer, which would drop
exactly the record that matters most.

Observation is best-effort and never fails the call. The bytes are
already on disk and will be committed, so returning an error would report
a non-rewrite to the caller while the rewrite stands. A failure to
observe logs at ERROR — that is the case where the trail itself broke.

Ordinary brain_edit on knowledge//wiki/ is deliberately NOT audited. Git
history already carries the same before/after bytes with a diff; the
asymmetry is that governance files change what later agents obey. Routine
note traffic in the same trail would also bury the rare governance record,
which is how an alert stops being read.

Red-first: the audit-contents and notification tests failed on an empty
sink before the observer existed. The three negative tests (refused call,
failed edit, ordinary note edit) passed from the start, pinning behaviour
that must not change.

Refs #97, #96, brain#4.
2026-08-20 17:30:24 +02: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