fix(brain): close round-3 review findings on provenance
- B1: search.extractProvenance locates the frontmatter with
frontmatter.Split (fence at column 0), so an indented "---" inside a
block scalar no longer hides provenance from brain_query.
- B2: brain_edit refuses to edit without a snapshot, and if the stamp
cannot be verified restores the note and reports failure; a failed
restore is reported as exactly that, never as "rolled back".
- B3: internal/provenance gets direct tests for the whole-document
backstop (a stamp that cannot read back is refused), standalone group
decoding, duplicates, lone CR, tab continuations and unclosable
fences. Removing verify or the restore now fails the suite.
- Principals YAML would read as non-strings (true, 123, null, ~) are
quoted instead of refused. An unclosable fence ("---x") is refused
rather than demoted into the body. writeHallNote creates the hall
directory only after the note passes, so a refused write leaves
nothing behind (brain#21).
- MCP brain_ingest (stub LLM) asserted to stamp the caller.
Refs #145 #144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"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
|
||||
@@ -260,3 +262,64 @@ func TestToolsCallAuditErrorOutcomeCarriesNoArgumentValues(t *testing.T) {
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,17 @@ func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawM
|
||||
// verified, the edit is rolled back rather than left on disk unattributed
|
||||
// (or carrying whatever provenance the edit wrote).
|
||||
var a brainEditArgs
|
||||
_ = json.Unmarshal(args, &a)
|
||||
var prior []byte
|
||||
if t, rerr := notes.Resolve(s.brainDir, a.Path, notes.ScopeNotes); rerr == nil {
|
||||
prior, _ = os.ReadFile(t.Abs)
|
||||
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 {
|
||||
@@ -124,8 +131,10 @@ func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawM
|
||||
// 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 t, rerr := notes.Resolve(s.brainDir, res.Path, notes.ScopeNotes); rerr == nil && prior != nil {
|
||||
_ = os.WriteFile(t.Abs, prior, 0o644)
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user