feat(mcp): record and alert on a governance-file rewrite (#97) #98
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user