Files
hyperguild/ingestion/internal/api/provenance.go
T
mathiasandClaude Opus 5.5 e615d6f144
CI / Lint / Test / Vet (pull_request) Successful in 45s
CI / Mirror to GitHub (pull_request) Skipped
fix(brain): provenance as a post-condition on every model-reachable write
The PR #147 review forged origin: static as a JWT principal through
three tools the first version never touched (brain_edit, brain_ingest_raw,
ingest_raw + brain_promote), and through YAML spellings a key-name match
missed (quoted, escaped, tagged, flow-mapped, merge keys, lone CR).

- api.StampProvenance removes every spelling of origin/written_by from
  the frontmatter and appends the real values. A line is dropped if
  EITHER yaml.v3 (decoding quotes, escapes, tags, flow maps) or the
  brain's line-oriented readers would read it as provenance; complex
  keys, orphan values and merge keys are dropped outright. Lone CRs in
  frontmatter become LF. Principal values that are not bare words are
  quoted.
- Applied as the last step of writeHallNote, UpdateNote, PromoteNote
  (re-attributed to the promoter), brain_edit (re-attributed to the
  editor) and both ingests (every page written or merged). REST
  /write and /promote carry no principal and stamp unknown.
- Ingest: newlines in scalar fields fold to spaces; a page type must be
  a plain lower-case name (../raw wrote outside wiki/, pre-existing).
- Audit: outcome is ok|error, never the error text, which echoed
  argument values.

Tests: every escape from the review is a table case asserting the
invariant on the file (parses as YAML, yaml.v3 and brain_get agree,
exactly the stamped lines). Plus REST /write, REST capture AuthPath,
body-line provenance in search, and quoted principals; two of the
review's surviving flips now go red. Three tests renamed so names claim
only what they assert.

Refs #145 #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:29:46 +02:00

173 lines
5.5 KiB
Go

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