package api import ( "fmt" "os" "path/filepath" "regexp" "strings" "gopkg.in/yaml.v3" sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter" ) // 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") } // StampProvenanceFile applies StampProvenance to a note on disk. 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() }