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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user