fix(brain): provenance keeps only provably plain frontmatter; pipeline stamps
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:
@@ -0,0 +1,213 @@
|
||||
// Package provenance stamps server-owned write provenance (hyperguild#145)
|
||||
// into a brain note's frontmatter: origin (static | jwt | unknown) and
|
||||
// written_by (the authenticated principal), so a reader can discount what a
|
||||
// connector session wrote after reading an untrusted page.
|
||||
//
|
||||
// The control is a post-condition applied by every write path a model can
|
||||
// reach. Two review rounds on PR #147 showed that deciding line by line which
|
||||
// lines are provenance loses to YAML's context-dependent features — an alias
|
||||
// key resolves only against an anchor elsewhere, and a `...` line ends the
|
||||
// document before the stamp. So Apply does not try to recognise provenance.
|
||||
// It keeps only frontmatter it can prove is plain:
|
||||
//
|
||||
// - a top-level line `key: value` whose key is a bare identifier,
|
||||
// - together with that key's continuation lines (indented, or `- ` items
|
||||
// after an empty value),
|
||||
// - where the group decodes ON ITS OWN, with yaml.v3, to a mapping with
|
||||
// exactly that one key,
|
||||
// - and the key is not origin/written_by and has not been seen before.
|
||||
//
|
||||
// Everything else — aliases, anchors used as keys, document markers, complex
|
||||
// keys, merge keys, flow maps, tags, quoted keys, duplicates — is dropped.
|
||||
// Then the stamp is appended and the whole result is decoded by yaml.v3 as a
|
||||
// backstop: if it does not decode to exactly the stamped values, Apply fails
|
||||
// and the caller refuses the write.
|
||||
package provenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
)
|
||||
|
||||
// Stamp is the provenance a write carries. An empty Origin means "unknown".
|
||||
type Stamp struct {
|
||||
Origin string
|
||||
WrittenBy string
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithStamp attaches the caller's provenance to ctx for code paths (the ingest
|
||||
// pipeline) that write notes far from where the principal is known.
|
||||
func WithStamp(ctx context.Context, s Stamp) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, s)
|
||||
}
|
||||
|
||||
// FromContext returns the stamp in ctx, or the unknown stamp.
|
||||
func FromContext(ctx context.Context) Stamp {
|
||||
if s, ok := ctx.Value(ctxKey{}).(Stamp); ok {
|
||||
return s
|
||||
}
|
||||
return Stamp{}
|
||||
}
|
||||
|
||||
// ErrUnverifiable means the stamped note would not read back as stamped.
|
||||
var ErrUnverifiable = errors.New("provenance: stamped frontmatter does not decode to the stamp")
|
||||
|
||||
var (
|
||||
keyLine = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_.-]*)[ ]*:(?:[ ]|$)(.*)$`)
|
||||
bareScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`)
|
||||
reserved = map[string]bool{"origin": true, "written_by": true}
|
||||
)
|
||||
|
||||
func (s Stamp) origin() string {
|
||||
if s.Origin == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s.Origin
|
||||
}
|
||||
|
||||
// Apply returns content with its frontmatter reduced to plain groups and the
|
||||
// stamp appended. Content without frontmatter gains a block.
|
||||
func Apply(content string, s Stamp) (string, error) {
|
||||
stamp := "origin: " + scalar(s.origin()) + "\n"
|
||||
if s.WrittenBy != "" {
|
||||
stamp += "written_by: " + scalar(s.WrittenBy) + "\n"
|
||||
}
|
||||
content = foldFrontmatterCR(content)
|
||||
block, rest, ok := sharedfm.Split([]byte(content))
|
||||
var out string
|
||||
if !ok {
|
||||
out = "---\n" + stamp + "---\n" + content
|
||||
} else {
|
||||
out = "---\n" + plainGroups(string(block)) + stamp + "---\n" + strings.TrimPrefix(string(rest), "\n")
|
||||
}
|
||||
if err := verify(out, s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ApplyFile applies the stamp to the note at absPath in place.
|
||||
func ApplyFile(absPath string, s Stamp) error {
|
||||
b, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read for provenance: %w", err)
|
||||
}
|
||||
out, err := Apply(string(b), s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == string(b) {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(out), 0o644); err != nil {
|
||||
return fmt.Errorf("write provenance: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// plainGroups keeps the provably plain key groups of a frontmatter block.
|
||||
func plainGroups(block string) string {
|
||||
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
|
||||
seen := map[string]bool{}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(lines); {
|
||||
line := lines[i]
|
||||
m := keyLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
i++ // not a plain key line: dropped, as is anything it would govern
|
||||
for i < len(lines) && isContinuation(lines[i], "") {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, value := m[1], strings.TrimSpace(m[2])
|
||||
j := i + 1
|
||||
for j < len(lines) && isContinuation(lines[j], value) {
|
||||
j++
|
||||
}
|
||||
group := strings.Join(lines[i:j], "\n") + "\n"
|
||||
lk := strings.ToLower(key)
|
||||
if !reserved[lk] && !seen[lk] && decodesToOnly(group, key) {
|
||||
seen[lk] = true
|
||||
b.WriteString(group)
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isContinuation reports whether line belongs to the preceding key: indented
|
||||
// lines always do; a top-level "- " item only follows a key with no value.
|
||||
func isContinuation(line, value string) bool {
|
||||
if line == "" {
|
||||
return false
|
||||
}
|
||||
if line[0] == ' ' {
|
||||
return true
|
||||
}
|
||||
return value == "" && strings.HasPrefix(line, "- ")
|
||||
}
|
||||
|
||||
func decodesToOnly(group, key string) bool {
|
||||
var m map[string]any
|
||||
if err := yaml.Unmarshal([]byte(group), &m); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := m[key]
|
||||
return ok && len(m) == 1
|
||||
}
|
||||
|
||||
func verify(doc string, s Stamp) error {
|
||||
block, _, ok := sharedfm.Split([]byte(doc))
|
||||
if !ok {
|
||||
return ErrUnverifiable
|
||||
}
|
||||
var m map[string]any
|
||||
if err := yaml.Unmarshal(block, &m); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnverifiable, err)
|
||||
}
|
||||
if m["origin"] != s.origin() {
|
||||
return ErrUnverifiable
|
||||
}
|
||||
if s.WrittenBy == "" {
|
||||
if _, has := m["written_by"]; has {
|
||||
return ErrUnverifiable
|
||||
}
|
||||
} else if m["written_by"] != s.WrittenBy {
|
||||
return ErrUnverifiable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// foldFrontmatterCR turns CRLF and lone CR inside a leading frontmatter block
|
||||
// into LF, so every reader agrees on where lines end.
|
||||
func foldFrontmatterCR(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 bare word as-is and anything else as a JSON string (valid
|
||||
// YAML double-quoted), so a principal name can never break the line.
|
||||
func scalar(v string) string {
|
||||
if bareScalar.MatchString(v) {
|
||||
return v
|
||||
}
|
||||
return fmt.Sprintf("%q", v)
|
||||
}
|
||||
Reference in New Issue
Block a user