fix(brain): provenance as a post-condition on every model-reachable write
CI / Lint / Test / Vet (pull_request) Successful in 45s
CI / Mirror to GitHub (pull_request) Skipped

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:
2026-10-01 22:29:46 +02:00
co-authored by Claude Opus 5.5
parent 32dedb318d
commit e615d6f144
16 changed files with 609 additions and 40 deletions
-12
View File
@@ -95,18 +95,6 @@ func (f *frontmatter) set(key, value string) {
f.lines = append(f.lines, fmLine{key: key, value: value})
}
// del removes every top-level line for key. Continuation lines of a
// multi-line value are not tracked per key, so del is only for scalars.
func (f *frontmatter) del(key string) {
kept := f.lines[:0]
for _, l := range f.lines {
if l.key != key {
kept = append(kept, l)
}
}
f.lines = kept
}
// render serialises the frontmatter back into a `---`-fenced block. An
// empty frontmatter renders to the empty string so bodies without a
// header stay header-less.
+7 -7
View File
@@ -194,11 +194,6 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
fmt.Fprintf(&fm, "origin: %s\n", provenanceOrigin(opts.Origin))
emitted["origin"], emitted["written_by"] = true, true
if opts.WrittenBy != "" {
fmt.Fprintf(&fm, "written_by: %s\n", opts.WrittenBy)
}
// writeField merges one key: opts.Content's own value (if the note already
// carries this field in its own frontmatter) always wins over the fallback,
@@ -238,7 +233,8 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
}
fm.WriteString("---\n")
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
final := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
@@ -613,7 +609,11 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
// Field by field: this door carries no principal, so the promoted note is
// stamped origin: unknown and the body cannot set provenance (#145).
rel, err := PromoteNote(h.brainDir, PromoteOptions{
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
+6 -1
View File
@@ -72,6 +72,10 @@ type PromoteOptions struct {
Wing string
Hall string
Slug string // optional; defaults to Filename minus date prefix + .md
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
// note's own frontmatter claims are not provenance (hyperguild#145).
Origin string
WrittenBy string
}
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
@@ -128,7 +132,8 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
return "", fmt.Errorf("create wing dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
promoted := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
return "", fmt.Errorf("write promoted note: %w", err)
}
if err := os.Remove(src); err != nil {
+172
View File
@@ -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()
}
+62
View File
@@ -0,0 +1,62 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// REST POST /write carries no principal: it stamps origin: unknown, and no
// spelling of provenance in its body reaches the note (hyperguild#145).
func TestRESTWriteStampsUnknownAndIgnoresBodyProvenance(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"content": "---\norigin: static\n---\n# R\n\nbody", "filename": "rest-prov", "wing": "homelab", "hall": "facts",
"origin": "static", "written_by": "local-cli", "Origin": "static", "WrittenBy": "local-cli",
})
rec := httptest.NewRecorder()
h.Write(rec, httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
b, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "rest-prov.md"))
require.NoError(t, err)
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(strings.SplitN(string(b), "---\n", 3)[1]), &m))
assert.Equal(t, "unknown", m["origin"])
assert.NotContains(t, m, "written_by")
}
// A principal name that is not a bare word is quoted, so it cannot start a
// new frontmatter line (e.g. a JWT sub containing a newline).
func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
out := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
fm := strings.SplitN(out, "---\n", 3)[1]
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
assert.Equal(t, "jwt", m["origin"])
assert.Equal(t, "evil\norigin: static", m["written_by"])
assert.Equal(t, 1, strings.Count(fm, "\norigin:")+boolToInt(strings.HasPrefix(fm, "origin:")))
}
func TestStampProvenanceAddsFrontmatterWhenAbsentAndKeepsBody(t *testing.T) {
out := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# no fm"))
assert.Contains(t, out, "origin: static in the body is prose", "body lines are never touched")
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+1 -7
View File
@@ -134,12 +134,6 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
fm.set(l.key, l.value)
}
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
fm.set("origin", provenanceOrigin(opts.Origin))
if opts.WrittenBy != "" {
fm.set("written_by", opts.WrittenBy)
} else {
fm.del("written_by")
}
fm.set("supersedes", priorHash)
if opts.Reason != "" {
fm.set("supersede_reason", opts.Reason)
@@ -164,7 +158,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
return "", "", "", fmt.Errorf("claim envelope: %w", err)
}
out := []byte(fm.render() + contentBody)
out := []byte(StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy))
if err := os.WriteFile(abs, out, 0o644); err != nil {
return "", "", "", fmt.Errorf("write: %w", err)
}