Compare commits

..
Author SHA1 Message Date
mathias b6ceccca26 docs: name brain-mcp and say where its code lives (#94) (#99)
CI / Lint / Test / Vet (push) Successful in 1m43s
CI / Mirror to GitHub (push) Skipped
A forge search for "brain-mcp" returned nothing for this repo: the description
mentioned neither the brain nor brain-mcp, and the README never used the string
or the hostname. Both appeared only in the generated CLAUDE.md/AGENTS.md. Since
#91 part 2 decided the code stays in ingestion, discoverability is answered by
naming.

Adds a "Where brain-mcp lives" section: the ingress, the package, a table
mapping what-you-are-looking-for to where-it-is (including that the brain
content is a separate repo), and why it is not standalone, with a pointer to
the ADR.

Also repoints the README's three forge links from the decommissioned
gitea.d-ma.be alias to git.d-ma.be. Dated files under docs/superpowers are
left alone — they record what was true when written.

Repo description and topics were set through the forge API, and NPM's
default-site was changed from congratulations to 404 so a retired hostname
reads as absent rather than as a moved repo.

Closes #94.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-08-22 06:10:06 +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
mathias eb9e3e69a9 fix(capture): name what escalated the classification in an I1 refusal (#91 part 1) (#95)
CI / Lint / Test / Vet (push) Successful in 31s
CI / Mirror to GitHub (push) Successful in 4s
The tickets array was never broken. Every ticket in the reported batch
targeted a client repo, and the I1 gate refuses a confidential capture
through a us-nexus origin at any length — reproduced live: length 1 and
20 both fine for an internal repo, length 1 refused for a client one.

What made it look like an array bug is that the refusal named neither the
offending target nor that a policy had said no, so with 20 tickets in
flight "the array is broken" was a reasonable conclusion. The refusal now
names the targets carrying the effective classification, and tells a
caller who declared confidential themselves exactly that.

Regression tests at length 1 and 20 pin the path that was already
correct; a third pins per-item partial success.

Closes #91 part 1.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-08-18 18:13:48 +00:00
mathias b7cf0efb9d chore: opt in to headless dispatch (dispatch#3)
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Successful in 4s
2026-08-18 06:05:18 +00:00
7 changed files with 328 additions and 28 deletions
+2
View File
@@ -0,0 +1,2 @@
# Presence of this file marks this repo as opt-in for headless dispatch.
# See dispatch#3.
+35 -3
View File
@@ -5,6 +5,11 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
workflows (TDD red/green/refactor), logs every session, and accumulates learnings workflows (TDD red/green/refactor), logs every session, and accumulates learnings
into a searchable brain. into a searchable brain.
**This repo is also where `brain-mcp` lives** — the MCP server at
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
and `capture` are served from. It is not a separate deployable; see
[Where brain-mcp lives](#where-brain-mcp-lives).
## Hypothesis ## Hypothesis
> We believe routing skill tasks through local models, backed by brain context, > We believe routing skill tasks through local models, backed by brain context,
@@ -20,7 +25,7 @@ usage, the fast-model routing path should be reconsidered.
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically. **hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different [agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
orchestration model. Skills (mathias/skills) are shared between both. orchestration model. Skills (mathias/skills) are shared between both.
## How it works ## How it works
@@ -44,6 +49,33 @@ brain/
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22) └── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
``` ```
## Where brain-mcp lives
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
endpoint, the REST API, and the claudewatcher together.
| You are looking for | It is here |
|---|---|
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
| Deployment manifests | `config/supervisor` |
### Why it is not its own repo
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
has exactly one importer — it is the MCP facade over the service's own
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
packages as a shared library, or maintaining a shim. The discoverability problem
that prompted the question is answered by naming it here instead.
Full reasoning, including the reversal trigger: brain
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
## Phase 1 tools (available now) ## Phase 1 tools (available now)
| Tool | What it does | | Tool | What it does |
@@ -58,7 +90,7 @@ brain/
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) | | `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12). > **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills). > They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
## Connect a project ## Connect a project
@@ -147,7 +179,7 @@ The routing pod selects models per skill call based on historical pass rate:
## Open issues ## Open issues
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items: See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop) - **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
- **#24** — reduce context burn from skill listing - **#24** — reduce context burn from skill listing
+18 -7
View File
@@ -144,6 +144,16 @@ func envInt(key string, fallback int) int {
return fallback 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 // buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
// set it builds the classification-aware DegradingSink (loki central + // set it builds the classification-aware DegradingSink (loki central +
// durable file buffer + optional ntfy) and starts the reconcile loop; // 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) logger.Error("capture audit buffer init", "err", err)
os.Exit(1) os.Exit(1)
} }
// Keep notifier as a nil interface (not a typed-nil) when unconfigured notifier := buildNotifier()
// 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
}
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval) audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds())) 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; // the existing /mcp OAuth connector. mcpSrv is already wrapped above;
// WithCapture mutates the same instance, so the tool appears live. // WithCapture mutates the same instance, so the tool appears live.
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) 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 { } else {
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)") logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
} }
+2
View File
@@ -49,6 +49,8 @@ type rpcError struct {
// Server handles MCP JSON-RPC over HTTP for the ingestion service. // Server handles MCP JSON-RPC over HTTP for the ingestion service.
type Server struct { type Server struct {
brainDir string brainDir string
govAudit capture.AuditSink
govNotify govNotifier
pipeline pipeline.Config pipeline pipeline.Config
llm pipeline.CompleteFunc llm pipeline.CompleteFunc
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
+85 -8
View File
@@ -6,8 +6,11 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes" "github.com/mathiasbq/hyperguild/ingestion/internal/notes"
) )
@@ -52,6 +55,20 @@ func noteToolDescriptors() []map[string]any {
} }
} }
// --- hyperguild#97: governance-rewrite observability -----------------------
// govNotifier raises an out-of-band alert. Declared here rather than importing
// internal/audit so the MCP package keeps depending on capture alone.
type govNotifier interface {
Notify(ctx context.Context, msg string) error
}
// WithGovernanceObserver attaches the audit sink and notifier that observe a
// successful brain_edit_root. Both are optional; either may be nil.
func (s *Server) WithGovernanceObserver(sink capture.AuditSink, n govNotifier) {
s.govAudit, s.govNotify = sink, n
}
type brainReadArgs struct { type brainReadArgs struct {
Path string `json:"path"` Path string `json:"path"`
} }
@@ -75,8 +92,17 @@ type brainEditArgs struct {
} }
// brainEdit is the model-facing editor: notes only. // brainEdit is the model-facing editor: notes only.
//
// Deliberately NOT audited (#97). A note edit is already fully reconstructible
// from git history, which carries the same before/after bytes plus a diff.
// Governance files get the extra trail because they change what later agents
// obey — the asymmetry is about instruction-carrying content, not about how
// hard the change is to reconstruct. Auditing every note edit would also bury
// the rare governance record under routine traffic, which is how an alert
// stops being read.
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) { func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
return s.editWithScope(args, notes.ScopeNotes) _, out, err := s.editWithScope(args, notes.ScopeNotes)
return out, err
} }
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on // brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
@@ -89,22 +115,72 @@ func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.
return nil, fmt.Errorf("brain_edit_root requires the local static principal; " + return nil, fmt.Errorf("brain_edit_root requires the local static principal; " +
"a governance file is not editable from a model-facing origin") "a governance file is not editable from a model-facing origin")
} }
out, err := s.editWithScope(args, notes.ScopeRoot) res, out, err := s.editWithScope(args, notes.ScopeRoot)
if err == nil { if err != nil {
// A refusal is not a rewrite. Recording or alerting on one would cry
// wolf, and the gate already logs its own denials.
return out, err
}
// A durable instruction rewrite should be loud, not silent. // A durable instruction rewrite should be loud, not silent.
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out)) slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
} s.observeGovernanceEdit(ctx, info.principal, res)
return out, err return out, err
} }
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (json.RawMessage, error) { // observeGovernanceEdit records and alerts on a rewrite that already landed.
//
// Best-effort by construction: the bytes are written and brain-sync will commit
// them within the quarter hour, so failing the call here would report a
// non-rewrite to the caller while the rewrite sits on disk. A failure to
// observe is therefore logged at ERROR — the one case where the trail itself is
// what broke, and the only remaining signal.
func (s *Server) observeGovernanceEdit(ctx context.Context, principal string, res notes.Result) {
// Path and hashes only. The notification leaves the cluster for a phone
// via a third-party topic; a hash identifies the rewrite, the bytes would
// leak whatever was written into an instruction file.
msg := fmt.Sprintf("brain_edit_root applied\npath=%s\nprincipal=%s\nbefore=%s\nafter=%s",
res.Path, principal, res.PrevContentHash, res.ContentHash)
if s.govNotify != nil {
if err := s.govNotify.Notify(ctx, msg); err != nil {
slog.Error("brain_edit_root notify failed", "path", res.Path, "err", err)
}
}
if s.govAudit == nil {
return
}
// Internal, not Confidential: a governance file is not client data, and
// Confidential would make DegradingSink REFUSE to record while loki is
// down rather than buffer — losing exactly the record that matters most.
outcome, err := s.govAudit.Reserve(ctx, classification.Internal)
if err != nil {
slog.Error("brain_edit_root audit reserve failed", "path", res.Path, "err", err)
return
}
entry := capture.AuditEntry{
Timestamp: time.Now().UTC(),
Principal: principal,
Actor: "brain_edit_root",
Harness: "mcp",
EffectiveClassification: classification.Internal.String(),
Items: []string{res.Path},
SecurityEvents: []string{fmt.Sprintf(
"governance file rewritten: %s before=%s after=%s",
res.Path, res.PrevContentHash, res.ContentHash)},
}
if err := s.govAudit.Record(ctx, entry, outcome); err != nil {
slog.Error("brain_edit_root audit record failed", "path", res.Path, "err", err)
}
}
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.Result, json.RawMessage, error) {
var a brainEditArgs var a brainEditArgs
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err) return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
} }
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope) res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
if err != nil { if err != nil {
return nil, err return notes.Result{}, nil, err
} }
// Reindex only when the edit could have changed what the index reads. // Reindex only when the edit could have changed what the index reads.
@@ -121,7 +197,8 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (json.Ra
res.ReindexError = ierr.Error() res.ReindexError = ierr.Error()
} }
} }
return json.Marshal(res) out, merr := json.Marshal(res)
return res, out, merr
} }
// wingOf returns the wing for a wiki path, or "" for knowledge/ and root. // wingOf returns the wing for a wiki path, or "" for knowledge/ and root.
+170
View File
@@ -2,13 +2,22 @@
package mcp_test package mcp_test
import ( import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp" "github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -125,3 +134,164 @@ func mustJSON(t *testing.T, v any) []byte {
require.NoError(t, err) require.NoError(t, err)
return b return b
} }
// ---- hyperguild#97: a governance rewrite must be observed, not just gated ----
// govSink captures audit records. Governance edits are rare, so the whole
// history is kept and asserted on rather than sampled.
type govSink struct {
entries []capture.AuditEntry
outcomes []capture.AuditOutcome
recErr error
}
func (s *govSink) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
return capture.AuditCentral, nil
}
func (s *govSink) Record(_ context.Context, e capture.AuditEntry, o capture.AuditOutcome) error {
s.entries = append(s.entries, e)
s.outcomes = append(s.outcomes, o)
return s.recErr
}
type govNotifier struct {
msgs []string
err error
}
func (n *govNotifier) Notify(_ context.Context, msg string) error {
n.msgs = append(n.msgs, msg)
return n.err
}
// govServer wires capture (which is what re-derives the static principal) plus
// the #97 observer, and returns the brain dir so the test can read the file.
func govServer(t *testing.T) (*mcp.Server, string, *govSink, *govNotifier) {
t.Helper()
dir := newBrainRepo(t)
cfg, err := classification.Load(dir)
require.NoError(t, err)
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
srv := mcp.NewServer(dir, nil, nil, nil)
srv.WithCapture(svc, nil, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
sink, notifier := &govSink{}, &govNotifier{}
srv.WithGovernanceObserver(sink, notifier)
return srv, dir, sink, notifier
}
// authCall is call() with an Authorization header, so the static principal is
// present and brain_edit_root's gate opens.
func authCall(t *testing.T, srv *mcp.Server, authz, name string, args map[string]any) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
}))
if authz != "" {
req.Header.Set("Authorization", authz)
}
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
return resp
}
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestBrainEditRoot_RecordsAnAuditEntryNamingPrincipalPathAndBothHashes(t *testing.T) {
// The gate proves a rewrite was ALLOWED. Only a record proves one HAPPENED
// — and brain-sync will commit it within 15 minutes authored `brain-sync`,
// so git history cannot tell it apart from routine note traffic.
srv, dir, sink, _ := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
})
require.NotContains(t, resp, "error", "%v", resp["error"])
require.Len(t, sink.entries, 1, "exactly one audit record per governance rewrite")
e := sink.entries[0]
assert.Equal(t, "local-cli", e.Principal)
assert.Contains(t, e.Items, "CLAUDE.md", "the record must name what was rewritten")
joined := strings.Join(e.SecurityEvents, " ")
assert.Contains(t, joined, sha256hex("# rules\n"), "before-hash must be recorded")
assert.Contains(t, joined, sha256hex("# rewritten\n"), "after-hash must be recorded")
assert.NotZero(t, e.Timestamp)
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
require.NoError(t, err)
assert.Equal(t, "# rewritten\n", string(got))
}
func TestBrainEditRoot_NotifiesWithPathAndHashesButNeverContent(t *testing.T) {
srv, _, _, notifier := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
})
require.NotContains(t, resp, "error", "%v", resp["error"])
require.Len(t, notifier.msgs, 1)
msg := notifier.msgs[0]
assert.Contains(t, msg, "CLAUDE.md")
assert.Contains(t, msg, "local-cli")
assert.Contains(t, msg, sha256hex("# rules\n"))
assert.Contains(t, msg, sha256hex("# rewritten\n"))
// The notification travels to a phone through a third-party topic. A hash
// identifies a rewrite; the bytes would leak whatever was written.
assert.NotContains(t, msg, "# rewritten", "no file content in the notification body")
assert.NotContains(t, msg, "# rules")
}
func TestBrainEditRoot_RefusedProducesNoRecordNoNotificationNoWrite(t *testing.T) {
srv, dir, sink, notifier := govServer(t)
// No Authorization header → no static principal → the gate stays shut.
resp := authCall(t, srv, "", "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
})
require.Contains(t, resp, "error")
assert.Empty(t, sink.entries, "a refusal is not a rewrite — recording one would cry wolf")
assert.Empty(t, notifier.msgs)
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
require.NoError(t, err)
assert.Equal(t, "# rules\n", string(got))
}
func TestBrainEditRoot_FailedEditProducesNoRecordNoNotification(t *testing.T) {
// Authorised but no match: the door opened and nothing went through it.
srv, _, sink, notifier := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "absent-from-the-file", "new_str": "x",
})
require.Contains(t, resp, "error")
assert.Empty(t, sink.entries)
assert.Empty(t, notifier.msgs)
}
func TestBrainEdit_OnOrdinaryNotesIsNotAudited(t *testing.T) {
// The decided asymmetry (#97): note edits are covered by git history, which
// carries the same before/after bytes with a diff. Governance files get the
// extra trail because they change what later agents obey, not because they
// are harder to reconstruct.
srv, _, sink, notifier := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
})
require.NotContains(t, resp, "error", "%v", resp["error"])
assert.Empty(t, sink.entries, "note edits must not fill the governance audit trail")
assert.Empty(t, notifier.msgs)
}
+6
View File
@@ -37,6 +37,10 @@ var pathLocks sync.Map // string -> *sync.Mutex
// Result is what a successful edit reports. // Result is what a successful edit reports.
type Result struct { type Result struct {
Path string `json:"path"` Path string `json:"path"`
// PrevContentHash is the sha256 of the file as it was before this edit.
// Paired with ContentHash it identifies a rewrite in an audit trail or an
// alert without carrying any of the bytes (hyperguild#97).
PrevContentHash string `json:"prev_content_hash"`
ContentHash string `json:"content_hash"` ContentHash string `json:"content_hash"`
BytesBefore int `json:"bytes_before"` BytesBefore int `json:"bytes_before"`
BytesAfter int `json:"bytes_after"` BytesAfter int `json:"bytes_after"`
@@ -149,8 +153,10 @@ func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
} }
sum := sha256.Sum256([]byte(after)) sum := sha256.Sum256([]byte(after))
prev := sha256.Sum256(before)
return Result{ return Result{
Path: t.Rel, Path: t.Rel,
PrevContentHash: hex.EncodeToString(prev[:]),
ContentHash: hex.EncodeToString(sum[:]), ContentHash: hex.EncodeToString(sum[:]),
BytesBefore: len(before), BytesBefore: len(before),
BytesAfter: len(after), BytesAfter: len(after),