feat(brain): audit-log every tools/call; server-owned write provenance (#147)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s

Closes #144 (audit line per tools/call) and #145 (origin/written_by stamped on every model-reachable write, surfaced in brain_query/brain_get).

Passed pr-gatekeeper after four rounds of adversarial review, verified by execution: #147

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #147.
This commit is contained in:
2026-10-01 21:06:36 +00:00
committed by mathias
co-authored by Claude Opus 5.5
parent f21be1aef6
commit a6da93332d
27 changed files with 1360 additions and 43 deletions
+48
View File
@@ -0,0 +1,48 @@
package mcp
import (
"context"
"encoding/json"
"log/slog"
"time"
)
// logToolCall emits one structured record per tools/call (hyperguild#144,
// OWASP MCP08): who called which tool, against which wing/hall, and how it
// ended. Brain content is never copied into the record — only its size — so
// the audit trail does not become a second, unscrubbed copy of the brain.
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
origin, principal := provenanceFrom(ctx)
attrs := []any{
"tool", name,
"principal", principal,
"origin", origin,
"duration_ms", time.Since(start).Milliseconds(),
}
var a struct {
Wing string `json:"wing"`
Hall string `json:"hall"`
Content string `json:"content"`
}
if json.Unmarshal(args, &a) == nil {
if a.Wing != "" {
attrs = append(attrs, "wing", a.Wing)
}
if a.Hall != "" {
attrs = append(attrs, "hall", a.Hall)
}
if a.Content != "" {
attrs = append(attrs, "content_bytes", len(a.Content))
}
}
// The outcome is a class, never the error text: handler errors echo
// argument values (a rejected half_life, a requested path), which would
// make the audit line a copy of what the caller sent (PR #147 review E6).
// The caller already received the full error in the tool response.
outcome := "ok"
if err != nil {
outcome = "error"
}
attrs = append(attrs, "outcome", outcome)
slog.InfoContext(ctx, "mcp tool call", attrs...)
}
+83
View File
@@ -0,0 +1,83 @@
package mcp_test
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// captureToolCallLog routes slog to a buffer for fn and returns the decoded
// "mcp tool call" records.
func captureToolCallLog(t *testing.T, fn func()) []map[string]any {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
fn()
var out []map[string]any
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
var rec map[string]any
if json.Unmarshal([]byte(l), &rec) == nil && rec["msg"] == "mcp tool call" {
out = append(out, rec)
}
}
return out
}
// hyperguild#144: brain_query/brain_read left no record of who read what.
func TestToolsCallLogsOneRecordWithToolPrincipalOriginAndScope(t *testing.T) {
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "Bearer "+capStaticTok, "brain_query",
map[string]any{"query": "anything", "wing": "homelab", "hall": "facts"})
})
require.Len(t, recs, 1)
r := recs[0]
assert.Equal(t, "brain_query", r["tool"])
assert.Equal(t, "local-cli", r["principal"])
assert.Equal(t, "static", r["origin"])
assert.Equal(t, "homelab", r["wing"])
assert.Equal(t, "facts", r["hall"])
assert.Equal(t, "ok", r["outcome"])
assert.Contains(t, r, "duration_ms")
}
func TestToolsCallWithoutDerivablePrincipalLogsOriginUnknown(t *testing.T) {
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "", "brain_query", map[string]any{"query": "x"})
})
require.Len(t, recs, 1)
assert.Equal(t, "unknown", recs[0]["origin"])
assert.Equal(t, "", recs[0]["principal"])
}
func TestToolsCallLogsErrorOutcome(t *testing.T) {
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{})
})
require.Len(t, recs, 1)
assert.Equal(t, "error", recs[0]["outcome"])
}
// The audit line must not become a second copy of brain content.
func TestToolsCallLogOmitsContentButRecordsItsSize(t *testing.T) {
const sentinel = "SENTINEL-BRAIN-CONTENT-91c2"
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# t\n\n" + sentinel, "wing": "testing", "hall": "facts", "filename": "audit-sentinel",
})
})
require.Len(t, recs, 1)
raw, _ := json.Marshal(recs[0])
assert.NotContains(t, string(raw), sentinel)
assert.EqualValues(t, len("# t\n\n"+sentinel), recs[0]["content_bytes"])
}
+26 -10
View File
@@ -16,6 +16,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
)
@@ -255,11 +256,14 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
// one implementation (capture uses the same store). The read-after-write
// handle {id, path, content_hash} comes back from the store; path is kept
// for backward compatibility.
origin, writtenBy := provenanceFrom(ctx)
ref, err := s.store.Write(ctx, capture.Note{
Content: a.Content,
Filename: a.Filename,
Wing: a.Wing,
Hall: a.Hall,
Content: a.Content,
Filename: a.Filename,
Wing: a.Wing,
Hall: a.Hall,
Origin: origin,
WrittenBy: writtenBy,
})
if err != nil {
return nil, err
@@ -304,11 +308,14 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
if a.Path != "" {
slug = a.Path
}
origin, writtenBy := provenanceFrom(ctx)
ref, err := s.store.Update(ctx, slug, capture.Note{
Content: a.Content,
Wing: a.Wing,
Hall: a.Hall,
Reason: a.Reason,
Content: a.Content,
Wing: a.Wing,
Hall: a.Hall,
Reason: a.Reason,
Origin: origin,
WrittenBy: writtenBy,
})
if err != nil {
return nil, err
@@ -387,8 +394,10 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
origin, writtenBy := provenanceFrom(ctx)
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
Origin: origin, WrittenBy: writtenBy,
})
if err != nil {
return nil, err
@@ -491,7 +500,7 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
if len(a.Pages) == 0 {
return nil, fmt.Errorf("pages must be non-empty")
}
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
result, err := pipeline.RunRaw(withCallerStamp(ctx), s.brainDir, a.Source, a.Pages, a.DryRun)
if err != nil {
return nil, fmt.Errorf("ingest: %w", err)
}
@@ -590,7 +599,7 @@ func (s *Server) sessionLog(ctx context.Context, args json.RawMessage) (json.Raw
}
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
result, err := pipeline.Run(withCallerStamp(ctx), s.pipeline, s.brainDir, content, source, dryRun)
if err != nil {
return nil, fmt.Errorf("ingest: %w", err)
}
@@ -609,3 +618,10 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
}
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
}
// withCallerStamp attaches the caller's provenance for the ingest pipeline,
// which stamps every page it writes or merges into (hyperguild#145).
func withCallerStamp(ctx context.Context) context.Context {
origin, writtenBy := provenanceFrom(ctx)
return provenance.WithStamp(ctx, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
}
@@ -0,0 +1,325 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"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/pipeline"
)
// Escapes found by the PR #147 gatekeeper review. Each test drives the real
// mcp.Server as a JWT principal "attacker" and asserts the provenance
// invariant on the file every reader sees.
const jwtAuthz = "Bearer jwt-token"
func jwtServer(t *testing.T) (*mcp.Server, string) {
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, capFakeValidator{subject: "attacker"}, capStaticTok, "local-cli",
capturehttp.NewOriginResolver(nil))
return srv, dir
}
var provenanceLine = regexp.MustCompile(`(?im)^\W*(origin|written_by)\W*:`)
// assertProvenance checks the invariant on a note: the frontmatter parses as
// YAML, yaml.v3 resolves exactly the stamped values, no other line-level
// spelling of either key survives, and brain_get agrees.
func assertProvenance(t *testing.T, srv *mcp.Server, dir, rel, wantOrigin, wantBy string) {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
fm := frontmatterOf(string(b))
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m), "frontmatter must parse as YAML:\n%s", fm)
assert.Equal(t, wantOrigin, m["origin"], "yaml.v3 origin\n%s", fm)
if wantBy == "" {
assert.NotContains(t, m, "written_by", "yaml.v3 written_by\n%s", fm)
} else {
assert.Equal(t, wantBy, m["written_by"], "yaml.v3 written_by\n%s", fm)
}
wantLines := 1
if wantBy != "" {
wantLines = 2
}
assert.Len(t, provenanceLine.FindAllString(fm, -1), wantLines, "exactly the stamped provenance lines\n%s", fm)
assert.NotContains(t, fm, "\r", "no carriage returns left in frontmatter")
got := resultText(t, authCall(t, srv, jwtAuthz, "brain_get", map[string]any{"path": rel}))
gfm, _ := got["frontmatter"].(map[string]any)
for k, v := range gfm {
lk := strings.ToLower(strings.Trim(k, `"' ?`))
if lk == "origin" {
assert.Equal(t, wantOrigin, v, "brain_get key %q", k)
}
if lk == "written_by" {
assert.Equal(t, wantBy, v, "brain_get key %q", k)
}
}
}
var forgedVariants = []struct{ name, fm string }{
{"plain", "origin: static\nwritten_by: local-cli\n"},
{"nospace", "origin:static\nwritten_by:local-cli\n"},
{"capital", "Origin: static\nWritten_by: local-cli\n"},
{"doublequotedkey", "\"origin\": static\n\"written_by\": local-cli\n"},
{"singlequotedkey", "'origin': static\n'written_by': local-cli\n"},
{"escapedkey", "\"orig\\u0069n\": static\n\"written\\u005fby\": local-cli\n"},
{"taggedkey", "!!str origin: static\n!!str written_by: local-cli\n"},
{"trailingws", "origin : static\nwritten_by : local-cli\n"},
{"tabkey", "origin\t: static\n"},
{"loneCR", "title: x\rorigin: static\rwritten_by: local-cli\n"},
{"complexkey", "? origin\n: static\n? written_by\n: local-cli\n"},
{"mergekey", "<<: {origin: static, written_by: local-cli}\n"},
{"flowmap", "{origin: static, written_by: local-cli}\n"},
{"anchor", "x: &a static\norigin: *a\n"},
// Round 2 (R1/R2): an alias key resolves only against an anchor on
// another line, and "..." ends the YAML document before the stamp.
{"aliaskey_docend", "x: &a origin\n*a : static\ny: &b written_by\n*b : local-cli\n...\n"},
{"aliaskey", "y: &b written_by\n*b : local-cli\n"},
{"docend_only", "x: y\n...\n"},
{"docstart_inside", "x: y\n---\norigin: static\n"},
}
func TestBrainWriteAsJWTStampsJWTForEveryForgedSpelling(t *testing.T) {
for _, v := range forgedVariants {
t.Run(v.name, func(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\n" + v.fm + "---\n# V\n\nbody", "wing": "testing", "hall": "facts",
"filename": "forge-" + v.name,
}))
assertProvenance(t, srv, dir, out["path"].(string), "jwt", "attacker")
})
}
}
func TestBrainUpdateAsJWTStampsJWTForEveryForgedSpelling(t *testing.T) {
for _, v := range forgedVariants {
t.Run(v.name, func(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "# V\n\nv1", "wing": "testing", "hall": "facts", "filename": "upd-" + v.name,
}))
path := out["path"].(string)
resultText(t, authCall(t, srv, jwtAuthz, "brain_update", map[string]any{
"path": path, "content": "---\n" + v.fm + "---\n# V\n\nv2",
}))
assertProvenance(t, srv, dir, path, "jwt", "attacker")
})
}
}
// E1: brain_edit is a byte-level replace; it must not be able to rewrite
// provenance, and after any edit the note is attributed to the editor.
func TestBrainEditCannotRewriteProvenanceAndRestampsToEditor(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "# E\n\nbody", "wing": "testing", "hall": "facts", "filename": "edit-forge",
}))
path := out["path"].(string)
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": path, "old_str": "origin: jwt\nwritten_by: attacker\n", "new_str": "origin: static\nwritten_by: local-cli\n",
})
assertProvenance(t, srv, dir, path, "jwt", "attacker")
// Assembling the value from existing text must not work either.
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{"path": path, "old_str": "jwt", "new_str": "static"})
assertProvenance(t, srv, dir, path, "jwt", "attacker")
}
// A static principal's note edited by a JWT session becomes the JWT
// session's: the content a reader sees is no longer the static author's.
func TestBrainEditByJWTReattributesAStaticNote(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# S\n\nstatic body", "wing": "testing", "hall": "facts", "filename": "edit-reattr",
}))
path := out["path"].(string)
resultText(t, authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": path, "old_str": "static body", "new_str": "injected body",
}))
assertProvenance(t, srv, dir, path, "jwt", "attacker")
}
// E1 via round 2's payload: the alias + document-end trick through brain_edit.
func TestBrainEditCannotForgeProvenanceWithAliasKeys(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\ntitle: t\n---\n# E\n\nbody", "wing": "testing", "hall": "facts", "filename": "edit-alias",
}))
path := out["path"].(string)
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": path, "old_str": "title: t\n", "new_str": "title: t\nx: &a origin\n*a : static\n...\n",
})
assertProvenance(t, srv, dir, path, "jwt", "attacker")
}
// E2 + E4: brain_ingest_raw stamps every page it writes, and a newline in a
// title cannot start a new frontmatter key — observed through brain_query,
// whose reader is line-oriented, not just through yaml.v3 (round 2, R4).
func TestBrainIngestRawStampsPagesAndNeutralisesTitleNewlines(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_ingest_raw", map[string]any{
"source": "zqsrc", "pages": []map[string]any{
{"title": "Zqplain", "type": "concept", "content": "plain body"},
{"title": "Zqinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "inj body"},
},
}))
pages := out["pages"].([]any)
require.NotEmpty(t, pages)
for _, p := range pages {
assertProvenance(t, srv, dir, p.(string), "jwt", "attacker")
b, _ := os.ReadFile(filepath.Join(dir, p.(string)))
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(frontmatterOf(string(b))), &m))
assert.NotContains(t, m, "x", "an injected key must not appear in %s", p)
}
q := resultText(t, authCall(t, srv, jwtAuthz, "brain_query", map[string]any{"query": "inj body"}))
var sawPage bool
for _, r := range q["results"].([]any) {
hit := r.(map[string]any)
// Generated aggregates (wiki/index.md, log.md, wing _index.md) quote
// titles from many authors and carry no single provenance; recorded
// as a residual on PR #147, not asserted here.
if !strings.HasPrefix(hit["path"].(string), "wiki/concepts/") {
continue
}
sawPage = true
assert.Equal(t, "jwt", hit["origin"], "line reader origin for %v", hit["path"])
assert.Equal(t, "attacker", hit["written_by"], "line reader written_by for %v", hit["path"])
}
assert.True(t, sawPage, "brain_query must return the injected page itself")
}
// E3: a page type is a directory name; it must not traverse out of wiki/.
func TestBrainIngestRawRejectsPathTraversingType(t *testing.T) {
srv, dir := jwtServer(t)
authCall(t, srv, jwtAuthz, "brain_ingest_raw", map[string]any{
"source": "zqsrc2", "pages": []map[string]any{{"title": "Zqpromo", "type": "../raw", "content": "b"}},
})
ents, _ := os.ReadDir(filepath.Join(dir, "raw"))
assert.Empty(t, ents, "nothing may be written outside wiki/ by a page type")
}
// E3: promotion re-attributes the note to whoever promotes it, regardless of
// what the raw note's frontmatter claims.
func TestBrainPromoteRestampsToPromoter(t *testing.T) {
srv, dir := jwtServer(t)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "planted.md"),
[]byte("---\ntitle: Planted\norigin: static\nwritten_by: local-cli\n---\n# Planted\n\nbody\n"), 0o644))
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_promote", map[string]any{
"filename": "planted.md", "wing": "testing", "hall": "facts",
}))
assertProvenance(t, srv, dir, out["path"].(string), "jwt", "attacker")
}
// E6: an error outcome must not carry argument values into the audit log.
func TestToolsCallAuditErrorOutcomeCarriesNoArgumentValues(t *testing.T) {
const sentinel = "SENTINEL-ERR-7d1e"
srv, _ := jwtServer(t)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\nhalf_life: " + sentinel + "\n---\n# H\n\nbody", "wing": "testing", "hall": "facts",
})
authCall(t, srv, jwtAuthz, "brain_update", map[string]any{"path": "wiki/testing/facts/" + sentinel + ".md", "content": "x"})
var sawError bool
for _, l := range strings.Split(buf.String(), "\n") {
var rec map[string]any
if json.Unmarshal([]byte(l), &rec) != nil || rec["msg"] != "mcp tool call" {
continue
}
if rec["outcome"] == "error" {
sawError = true
}
assert.NotContains(t, l, sentinel)
}
assert.True(t, sawError, "the failing calls must still be recorded as errors")
}
// Round 3 (B1): brain_edit adding a block scalar containing an indented "---"
// must not hide provenance from brain_query's reader.
func TestBrainEditBlockScalarFenceDoesNotHideProvenanceFromQuery(t *testing.T) {
srv, _ := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\nk: v\n---\n# E\n\nzqrthreeeditdash", "wing": "testing", "hall": "facts", "filename": "edit-dash",
}))
resultText(t, authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": out["path"], "old_str": "k: v\n", "new_str": "k: |\n ---\n",
}))
q := resultText(t, authCall(t, srv, jwtAuthz, "brain_query", map[string]any{"query": "zqrthreeeditdash"}))
hit := q["results"].([]any)[0].(map[string]any)
assert.Equal(t, "jwt", hit["origin"])
assert.Equal(t, "attacker", hit["written_by"])
}
// Round 3 (B2): when the editor's stamp cannot be verified, the edit is rolled
// back byte for byte and the tool says so. A JWT subject with an invalid UTF-8
// byte cannot round-trip through YAML, which forces that path.
func TestBrainEditRollsBackWhenTheStampCannotBeVerified(t *testing.T) {
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, capFakeValidator{subject: "bad\xff"}, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# R\n\nstatic body", "wing": "testing", "hall": "facts", "filename": "edit-rollback",
}))
path := out["path"].(string)
before, err := os.ReadFile(filepath.Join(dir, path))
require.NoError(t, err)
resp := authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{"path": path, "old_str": "static body", "new_str": "injected"})
require.NotNil(t, resp["error"], "the edit must be reported as failed")
assert.Contains(t, resp["error"].(map[string]any)["message"], "rolled back")
after, err := os.ReadFile(filepath.Join(dir, path))
require.NoError(t, err)
assert.Equal(t, string(before), string(after), "the note must be byte-identical to before the edit")
}
// MCP brain_ingest (the LLM path) attributes its pages to the caller.
func TestBrainIngestViaMCPStampsCaller(t *testing.T) {
dir := newBrainRepo(t)
cfg, err := classification.Load(dir)
require.NoError(t, err)
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
return `[{"title":"Zqllmpage","type":"concept","content":"zqllm body"}]`, nil
}}
srv := mcp.NewServer(dir, &stub, nil, nil)
srv.WithCapture(svc, capFakeValidator{subject: "attacker"}, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_ingest", map[string]any{"content": "article text", "source": "zqllm"}))
pages := out["pages"].([]any)
require.NotEmpty(t, pages)
for _, p := range pages {
assertProvenance(t, srv, dir, p.(string), "jwt", "attacker")
}
}
+124
View File
@@ -0,0 +1,124 @@
package mcp_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// resultText decodes the tool result payload of a tools/call response.
func resultText(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
require.NotContains(t, resp, "error", "tool call failed: %v", resp["error"])
content := resp["result"].(map[string]any)["content"].([]any)
var out map[string]any
require.NoError(t, json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &out))
return out
}
func readNote(t *testing.T, dir, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
return string(b)
}
func frontmatterOf(note string) string {
parts := strings.SplitN(note, "---\n", 3)
if len(parts) < 3 {
return ""
}
return parts[1]
}
// hyperguild#145: a brain entry records which principal wrote it, so a reader
// can tell a local-CLI note from one a connector session wrote after reading
// an untrusted page.
func TestBrainWriteStampsPrincipalProvenance(t *testing.T) {
srv, dir, _, _ := govServer(t)
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-static",
}))
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
assert.Contains(t, fm, "origin: static\n")
assert.Contains(t, fm, "written_by: local-cli\n")
}
func TestBrainWriteWithoutPrincipalStampsOriginUnknown(t *testing.T) {
srv, dir, _, _ := govServer(t)
out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{
"content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-unknown",
}))
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
assert.Contains(t, fm, "origin: unknown\n")
assert.NotContains(t, fm, "written_by:")
}
// The control: a caller cannot claim a more trusted origin by putting it in
// its own frontmatter.
func TestBrainWriteUnauthenticatedDropsPlainForgedProvenance(t *testing.T) {
srv, dir, _, _ := govServer(t)
forged := "---\norigin: static\nwritten_by: local-cli\n---\n# F\n\nbody"
out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{
"content": forged, "wing": "testing", "hall": "facts", "filename": "prov-forged",
}))
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
assert.Contains(t, fm, "origin: unknown\n")
assert.NotContains(t, fm, "origin: static")
assert.NotContains(t, fm, "written_by: local-cli")
assert.Equal(t, 1, strings.Count(fm, "origin:"), "exactly one origin line")
}
// brain_update replaces the content wholesale, so provenance follows the
// last writer — and a forged fragment must not survive the merge either.
func TestBrainUpdateRestampsProvenanceToTheUpdater(t *testing.T) {
srv, dir, _, _ := govServer(t)
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# U\n\nv1", "wing": "testing", "hall": "facts", "filename": "prov-update",
}))
path := out["path"].(string)
resultText(t, authCall(t, srv, "", "brain_update", map[string]any{
"path": path, "content": "---\norigin: static\nwritten_by: local-cli\n---\n# U\n\nv2",
}))
fm := frontmatterOf(readNote(t, dir, path))
assert.Contains(t, fm, "origin: unknown\n")
assert.NotContains(t, fm, "written_by: local-cli")
assert.Equal(t, 1, strings.Count(fm, "origin:"))
}
func TestBrainQueryReturnsProvenance(t *testing.T) {
srv, _, _, _ := govServer(t)
resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# Zebrafinch\n\nzebrafinch provenance probe", "wing": "testing", "hall": "facts", "filename": "prov-query",
}))
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{"query": "zebrafinch"}))
results := out["results"].([]any)
require.NotEmpty(t, results)
hit := results[0].(map[string]any)
assert.Equal(t, "static", hit["origin"])
assert.Equal(t, "local-cli", hit["written_by"])
}
// capture is the door a claude.ai connector session uses, so it is the one
// whose provenance matters most: a JWT principal must be stamped as such.
func TestMCPCaptureStampsJWTProvenance(t *testing.T) {
srv, brainDir := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"})
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
"insights": []map[string]any{{"text": "Capture provenance probe", "wing": "hyperguild", "hall": "facts"}},
})
require.Nil(t, resp["error"], "got error: %v", resp["error"])
matches, err := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md"))
require.NoError(t, err)
require.Len(t, matches, 1)
b, err := os.ReadFile(matches[0])
require.NoError(t, err)
fm := frontmatterOf(string(b))
assert.Contains(t, fm, "origin: jwt\n")
assert.Contains(t, fm, "written_by: koala-cli\n")
}
+3
View File
@@ -16,6 +16,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
@@ -229,7 +230,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx = withPrincipal(ctx, principal, viaStatic)
}
}
start := time.Now()
out, err := s.handleCall(ctx, p.Name, p.Arguments)
logToolCall(ctx, p.Name, p.Arguments, start, err)
if err != nil {
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
break
+15
View File
@@ -90,6 +90,7 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
// Principal and origin are server-derived — never taken from the body.
in.Context.Principal = info.principal
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
in.Context.AuthPath, _ = provenanceFrom(ctx)
rec, err := s.capture.svc.Capture(ctx, in)
if err != nil {
@@ -99,3 +100,17 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
}
return json.Marshal(rec)
}
// provenanceFrom maps the authenticated principal in ctx to the provenance
// stamped on a brain write (hyperguild#145). No principal → "unknown", never
// silence: an unstamped note must not read as trusted.
func provenanceFrom(ctx context.Context) (origin, writtenBy string) {
info, ok := ctx.Value(principalKey).(principalInfo)
if !ok {
return "unknown", ""
}
if info.viaStatic {
return "static", info.principal
}
return "jwt", info.principal
}
+36 -3
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"log/slog"
"os"
"strings"
"time"
@@ -103,9 +105,40 @@ type brainEditArgs struct {
// 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) {
_, out, err := s.editWithScope(args, notes.ScopeNotes)
return out, err
func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
// Snapshot before editing: if the edited note cannot be stamped and
// verified, the edit is rolled back rather than left on disk unattributed
// (or carrying whatever provenance the edit wrote).
var a brainEditArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
target, err := notes.Resolve(s.brainDir, a.Path, notes.ScopeNotes)
if err != nil {
return nil, err
}
// No snapshot, no edit: without one a failed stamp could not be undone.
prior, err := os.ReadFile(target.Abs)
if err != nil {
return nil, fmt.Errorf("snapshot before edit: %w", err)
}
res, out, err := s.editWithScope(args, notes.ScopeNotes)
if err != nil {
return out, err
}
// A byte-level replace can rewrite any line, including provenance, and
// whoever edits a note is the author of what a reader now sees: re-stamp
// to the editor after every edit (PR #147 review, escape E1).
origin, writtenBy := provenanceFrom(ctx)
if serr := api.StampProvenanceFile(s.brainDir, res.Path, origin, writtenBy); serr != nil {
if werr := os.WriteFile(target.Abs, prior, 0o644); werr != nil {
// Say exactly what state the note is in: edited, unverified.
return nil, fmt.Errorf("stamp provenance failed (%v) AND the edit could NOT be rolled back: %w; "+
"%s carries the edit under its previous attribution", serr, werr, res.Path)
}
return nil, fmt.Errorf("edit rolled back, stamp provenance: %w", serr)
}
return out, nil
}
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
+5 -2
View File
@@ -66,8 +66,11 @@ func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
require.NoError(t, err)
assert.Equal(t, "---\ntitle: 'n'\n---\n\nalpha\nBETA\n", string(got),
"only the matched bytes may change")
// Only the matched bytes change in the body; the one deliberate addition
// is the editor's provenance stamp (hyperguild#145 — no principal here,
// so origin: unknown).
assert.Equal(t, "---\ntitle: 'n'\norigin: unknown\n---\n\nalpha\nBETA\n", string(got),
"only the matched bytes and the provenance stamp may change")
}
func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {