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:
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user