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.
This commit is contained in:
2026-08-20 17:30:24 +02:00
parent 055af368cd
commit c2cdcb07f1
5 changed files with 291 additions and 25 deletions
+18 -7
View File
@@ -144,6 +144,16 @@ func envInt(key string, fallback int) int {
return fallback
}
// buildNotifier returns the ntfy notifier, or a nil interface when
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
// DegradingSink/Reconcile and the governance observer skip it cleanly.
func buildNotifier() audit.Notifier {
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n
}
return nil
}
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
// set it builds the classification-aware DegradingSink (loki central +
// durable file buffer + optional ntfy) and starts the reconcile loop;
@@ -161,12 +171,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
logger.Error("capture audit buffer init", "err", err)
os.Exit(1)
}
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
// so DegradingSink/Reconcile skip it cleanly.
var notifier audit.Notifier
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
notifier = n
}
notifier := buildNotifier()
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
@@ -490,7 +495,13 @@ func main() {
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
// WithCapture mutates the same instance, so the tool appears live.
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
// hyperguild#97: a governance-file rewrite gets the same trail a
// capture already gets, plus an out-of-band alert. Wired here because
// brain_edit_root needs the capture plumbing anyway — that is what
// re-derives the static principal its gate checks.
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
"governance_observer", true)
} else {
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
}