Round 2 of the PR #147 review forged origin: static for yaml.v3 with an alias key plus a "..." document-end line: per-line judgement cannot see context-dependent YAML. It also found REST ingest writing pages with no provenance (stampPages lived only in the MCP server). - New package internal/provenance. Apply keeps only top-level groups `key: value` with a bare-identifier key whose group decodes on its own to exactly that key, drops reserved and duplicate keys and everything else (aliases, anchors as keys, document markers, complex/merge keys, flow maps, tags, quoted keys), appends the stamp, then decodes the whole result with yaml.v3 and refuses the write unless it reads back exactly the stamp. api.StampProvenance wraps it and now returns an error, which every caller propagates. - The ingest pipeline stamps every page it writes or merges, from provenance.FromContext(ctx); MCP attaches the caller's stamp, REST and the watcher stamp unknown. RunRaw takes a ctx. - brain_edit snapshots the note and rolls the edit back if the stamp cannot be verified. Tests: R1/R2 payloads join the write/update variant table, plus brain_edit with the alias payload; REST /ingest-raw (title newline checked with the line-oriented reader) and REST /ingest (stub LLM) stamp unknown. Two existing byte-exact tests updated for the stamp line. Refs #145 #144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
11 KiB
Go
263 lines
11 KiB
Go
package mcp_test
|
|
|
|
import (
|
|
"bytes"
|
|
"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"
|
|
)
|
|
|
|
// 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")
|
|
}
|