fix(brain): provenance keeps only provably plain frontmatter; pipeline stamps
CI / Lint / Test / Vet (pull_request) Successful in 43s
CI / Mirror to GitHub (pull_request) Skipped

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>
This commit is contained in:
2026-10-01 22:55:53 +02:00
co-authored by Claude Opus 5.5
parent e615d6f144
commit fcae16569d
11 changed files with 367 additions and 204 deletions
+6 -11
View File
@@ -125,15 +125,6 @@ type WriteNoteOptions struct {
WrittenBy string
}
// provenanceOrigin normalises an origin for the frontmatter: absence must
// never read as trusted, so empty becomes "unknown".
func provenanceOrigin(o string) string {
if o == "" {
return "unknown"
}
return o
}
// WriteNote writes a markdown note into the brain. Returns the path
// relative to brainDir (forward-slashed). Filename traversal is rejected.
func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
@@ -233,7 +224,10 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
}
fm.WriteString("---\n")
final := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
@@ -553,7 +547,8 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
return
}
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
// No principal on this door: pages are stamped origin: unknown (#145).
result, err := pipeline.RunRaw(r.Context(), h.brainDir, req.Source, req.Pages, req.DryRun)
if err != nil {
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
writeError(w, http.StatusInternalServerError, "ingest error")
+4 -1
View File
@@ -132,7 +132,10 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
return "", fmt.Errorf("create wing dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
promoted := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
return "", fmt.Errorf("write promoted note: %w", err)
}
+8 -159
View File
@@ -1,172 +1,21 @@
package api
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
)
// Provenance (hyperguild#145) is server-owned: origin and written_by say
// which authenticated principal last wrote a note, so a reader can discount
// what a connector session wrote after reading an untrusted page.
//
// The control is a post-condition, not a set of per-door rules: every write
// path a model can reach ends with StampProvenance, which removes EVERY
// spelling of the two keys from the frontmatter and appends the real values.
// Deciding what counts as a spelling is delegated to yaml.v3 itself — each
// frontmatter line is decoded the way a YAML reader would decode it — so
// quoted, escaped, tagged, flow-mapped and merged keys are all recognised
// (PR #147 review: each of those survived a key-name string match).
var plainScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`)
// provenanceKeys are compared after yaml decoding and lower-casing.
var provenanceKeys = map[string]bool{"origin": true, "written_by": true}
// StampProvenance returns content with all provenance spellings removed from
// its frontmatter and origin (empty → "unknown") and written_by (omitted when
// empty) appended. Content without frontmatter gains a block.
func StampProvenance(content, origin, writtenBy string) string {
content = normaliseFenceNewlines(content)
stamp := "origin: " + scalar(provenanceOrigin(origin)) + "\n"
if writtenBy != "" {
stamp += "written_by: " + scalar(writtenBy) + "\n"
}
block, rest, ok := sharedfm.Split([]byte(content))
if !ok {
return "---\n" + stamp + "---\n" + content
}
// block is the frontmatter lines without the opening fence, ending in the
// newline before the closing fence; rest starts with the newline after it.
var kept []string
if len(block) > 0 {
for _, l := range strings.Split(strings.TrimSuffix(string(block), "\n"), "\n") {
if !isProvenanceLine(l) {
kept = append(kept, l)
}
}
}
return "---\n" + joinLines(kept) + stamp + "---\n" + strings.TrimPrefix(string(rest), "\n")
// StampProvenance applies server-owned provenance (hyperguild#145) to note
// content. See package provenance for the rule; an error means the stamped
// note would not read back as stamped, and the write must be refused.
func StampProvenance(content, origin, writtenBy string) (string, error) {
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
}
// StampProvenanceFile applies StampProvenance to a note on disk.
// StampProvenanceFile applies StampProvenance to a brain-relative note path.
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
_, abs, err := resolveWithin(brainDir, relPath)
if err != nil {
return err
}
b, err := os.ReadFile(abs)
if err != nil {
return fmt.Errorf("read for provenance: %w", err)
}
out := StampProvenance(string(b), origin, writtenBy)
if out == string(b) {
return nil
}
if err := os.WriteFile(abs, []byte(out), 0o644); err != nil {
return fmt.Errorf("write provenance %s: %w", filepath.ToSlash(relPath), err)
}
return nil
}
// isProvenanceLine reports whether a frontmatter line could, to any YAML
// reader, set origin or written_by. It errs towards dropping: a line it drops
// wrongly loses attacker-shaped metadata; a line it keeps wrongly is a forgery.
func isProvenanceLine(line string) bool {
t := strings.TrimSpace(line)
switch {
case t == "":
return false
case strings.HasPrefix(t, "?"), strings.HasPrefix(t, ":"), strings.HasPrefix(t, "<<"):
// Complex keys, their orphaned values, and merge keys: none appear in
// frontmatter the brain writes, and each can introduce a key a
// line-oriented check cannot see.
return true
}
// Two readers, two verdicts, and a line is dropped if EITHER would see
// provenance: yaml.v3 decodes quoting, escapes, tags and flow maps, while
// the brain's line-oriented readers split on the first colon — to them
// "origin:static" is a key, to YAML it is a plain scalar.
return yamlSeesProvenance(t) || textSeesProvenance(t)
}
func yamlSeesProvenance(t string) bool {
var v any
if err := yaml.Unmarshal([]byte(t), &v); err != nil {
return false
}
switch m := v.(type) {
case map[string]any:
for k := range m {
if provenanceKeys[strings.ToLower(strings.TrimSpace(k))] {
return true
}
}
case map[any]any:
for k := range m {
if provenanceKeys[strings.ToLower(strings.TrimSpace(fmt.Sprint(k)))] {
return true
}
}
}
return false
}
// textSeesProvenance takes the text before the first colon, drops YAML tags,
// brackets and quotes, and compares case-insensitively.
func textSeesProvenance(t string) bool {
key, _, found := strings.Cut(t, ":")
if !found {
return false
}
key = strings.TrimSpace(key)
for strings.HasPrefix(key, "!") {
_, after, ok := strings.Cut(key, " ")
if !ok {
break
}
key = strings.TrimSpace(after)
}
key = strings.ToLower(strings.Trim(key, "{[\"' \t"))
return provenanceKeys[key]
}
// normaliseFenceNewlines turns a lone CR inside the leading frontmatter
// block into LF, so "title: x\rorigin: static" is two lines to every reader,
// including this one. CRLF is already folded by frontmatter.Split.
func normaliseFenceNewlines(content string) string {
s := strings.ReplaceAll(content, "\r\n", "\n")
if !strings.HasPrefix(s, "---\n") {
return content
}
end := strings.Index(s[4:], "\n---")
if end < 0 {
return content
}
return strings.ReplaceAll(s[:4+end], "\r", "\n") + s[4+end:]
}
// scalar renders a value plainly when it is a safe bare word, otherwise as a
// JSON string (valid YAML double-quoted), so a principal name can never break
// the line.
func scalar(v string) string {
if plainScalar.MatchString(v) {
return v
}
return fmt.Sprintf("%q", v)
}
func joinLines(lines []string) string {
var b strings.Builder
for _, l := range lines {
b.WriteString(l)
b.WriteByte('\n')
}
return b.String()
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
}
+50 -2
View File
@@ -39,7 +39,8 @@ func TestRESTWriteStampsUnknownAndIgnoresBodyProvenance(t *testing.T) {
// A principal name that is not a bare word is quoted, so it cannot start a
// new frontmatter line (e.g. a JWT sub containing a newline).
func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
out := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
out, err := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
require.NoError(t, err)
fm := strings.SplitN(out, "---\n", 3)[1]
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
@@ -49,7 +50,8 @@ func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
}
func TestStampProvenanceAddsFrontmatterWhenAbsentAndKeepsBody(t *testing.T) {
out := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
out, err := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
require.NoError(t, err)
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# no fm"))
assert.Contains(t, out, "origin: static in the body is prose", "body lines are never touched")
}
@@ -60,3 +62,49 @@ func boolToInt(b bool) int {
}
return 0
}
// Round 2 (R3/R4): the REST ingest doors write through the pipeline, which
// now stamps every page; a title newline cannot forge provenance for the
// brain's line-oriented reader.
func TestRESTIngestRawStampsUnknownAndTitleNewlineCannotForge(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"source": "zqrest", "pages": []map[string]any{
{"title": "Zqrestinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "zqrestinj body"},
},
})
rec := httptest.NewRecorder()
h.IngestRaw(rec, httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp struct{ Pages []string }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Pages)
for _, p := range resp.Pages {
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
require.NoError(t, err)
fm := strings.SplitN(string(b), "---\n", 3)[1]
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
assert.Equal(t, "unknown", m["origin"])
assert.NotContains(t, m, "written_by")
for _, line := range strings.Split(fm, "\n") {
assert.False(t, strings.HasPrefix(line, "origin: static"), "line reader sees forged origin in %s", p)
}
}
}
func TestRESTIngestStampsUnknown(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{"content": "Some article text about shaping work.", "source": "zqllm"})
rec := httptest.NewRecorder()
h.Ingest(rec, httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp struct{ Pages []string }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Pages, "stub pipeline must write at least one page")
for _, p := range resp.Pages {
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
require.NoError(t, err)
assert.Contains(t, strings.SplitN(string(b), "---\n", 3)[1], "origin: unknown\n", p)
}
}
+5 -1
View File
@@ -158,7 +158,11 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
return "", "", "", fmt.Errorf("claim envelope: %w", err)
}
out := []byte(StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy))
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
if err != nil {
return "", "", "", err
}
out := []byte(stamped)
if err := os.WriteFile(abs, out, 0o644); err != nil {
return "", "", "", fmt.Errorf("write: %w", err)
}