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
+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})
}