// 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 switch { case !ok && strings.HasPrefix(strings.ReplaceAll(content, "\r\n", "\n"), "---\n"): // A fence was opened but cannot be closed (e.g. a "---x" line). // Prepending a second block would demote the original frontmatter // into the body, so refuse instead (PR #147 review round 3). return "", fmt.Errorf("%w: unclosed frontmatter fence", ErrUnverifiable) case !ok: out = "---\n" + stamp + "---\n" + content default: 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 only when YAML reads it back as that same // string; anything else (true, 123, null, ~, punctuation, newlines) becomes // a double-quoted string, so a principal name can never break the line or // change type. func scalar(v string) string { if bareScalar.MatchString(v) { var back any if yaml.Unmarshal([]byte(v), &back) == nil { if s, ok := back.(string); ok && s == v { return v } } } return fmt.Sprintf("%q", v) }