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>
173 lines
5.5 KiB
Go
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()
|
|
}
|